Compare commits
48 Commits
feat/quint
...
cojson-sto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
336cc1f0fe | ||
|
|
cc2ca5c23c | ||
|
|
3664385113 | ||
|
|
2b2ecdaf3d | ||
|
|
6dbb05320a | ||
|
|
ac3e694f4e | ||
|
|
143156cd6a | ||
|
|
1a182f07de | ||
|
|
7e7e7ebb51 | ||
|
|
0966a90f3d | ||
|
|
cd2f0846db | ||
|
|
c2e411d056 | ||
|
|
be5211d088 | ||
|
|
747f73d168 | ||
|
|
7501702f7b | ||
|
|
16fb9fab5f | ||
|
|
82de51c93d | ||
|
|
5d96991981 | ||
|
|
694b168fb4 | ||
|
|
feaa69ebdd | ||
|
|
d5fa172b17 | ||
|
|
96de15593b | ||
|
|
5ba03ebc70 | ||
|
|
4609cebed6 | ||
|
|
06d21b9529 | ||
|
|
f3426beaf5 | ||
|
|
8b3e038a98 | ||
|
|
4002d6afb9 | ||
|
|
7dd128962d | ||
|
|
8fb1748433 | ||
|
|
c8644bf678 | ||
|
|
269ee94338 | ||
|
|
dae80eeba8 | ||
|
|
ce54667b4d | ||
|
|
5963658e28 | ||
|
|
71c1411bbd | ||
|
|
71b221dc79 | ||
|
|
2d11d448dc | ||
|
|
2d42fc9b34 | ||
|
|
c9bda7e1e3 | ||
|
|
476f2d7eee | ||
|
|
1ba3a2ca34 | ||
|
|
7dd3d005a3 | ||
|
|
2c2dfb52d4 | ||
|
|
d33917fbaa | ||
|
|
f0c73d9cc6 | ||
|
|
d9324a9809 | ||
|
|
f7b5454cc6 |
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"name": "betterauth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"email": "email dev --dir src/components/emails"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icons-pack/react-simple-icons": "^12.8.0",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"better-auth": "^1.2.4",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-react-auth-betterauth": "workspace:*",
|
||||
"jazz-betterauth-client-plugin": "workspace:*",
|
||||
"jazz-betterauth-server-plugin": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"react-email": "^4.0.11",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "catalog:default"
|
||||
}
|
||||
"name": "betterauth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"email": "email dev --dir src/components/emails"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icons-pack/react-simple-icons": "^12.8.0",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"better-auth": "^1.2.4",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-react-auth-betterauth": "workspace:*",
|
||||
"jazz-betterauth-client-plugin": "workspace:*",
|
||||
"jazz-betterauth-server-plugin": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"react-email": "^4.0.11",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
"@bacons/text-decoder": "^0.0.0",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.11",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"expo": "catalog:expo",
|
||||
"expo-clipboard": "catalog:expo",
|
||||
"expo-secure-store": "catalog:expo",
|
||||
"expo-sqlite": "catalog:expo",
|
||||
"expo": "54.0.0-canary-20250701-6a945c5",
|
||||
"expo-clipboard": "^7.1.4",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-sqlite": "~15.2.10",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:expo",
|
||||
"react-native": "catalog:expo",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.80.0",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"readable-stream": "^4.7.0"
|
||||
},
|
||||
@@ -29,4 +29,4 @@
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"@react-navigation/native": "7.1.14",
|
||||
"@react-navigation/native-stack": "7.3.19",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:rn",
|
||||
"react-native": "catalog:rn",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.80.0",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-mmkv": "3.3.0",
|
||||
"react-native-safe-area-context": "5.5.0",
|
||||
@@ -31,16 +31,16 @@
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native-community/cli": "catalog:rn",
|
||||
"@react-native-community/cli-platform-android": "catalog:rn",
|
||||
"@react-native-community/cli-platform-ios": "catalog:rn",
|
||||
"@react-native/babel-preset": "catalog:rn",
|
||||
"@react-native/eslint-config": "catalog:rn",
|
||||
"@react-native/metro-config": "catalog:rn",
|
||||
"@react-native/typescript-config": "catalog:rn",
|
||||
"@react-native-community/cli": "19.0.0",
|
||||
"@react-native-community/cli-platform-android": "19.0.0",
|
||||
"@react-native-community/cli-platform-ios": "19.0.0",
|
||||
"@react-native/babel-preset": "0.80.0",
|
||||
"@react-native/eslint-config": "0.80.0",
|
||||
"@react-native/metro-config": "0.80.0",
|
||||
"@react-native/typescript-config": "0.80.0",
|
||||
"@rnx-kit/metro-config": "^2.0.1",
|
||||
"@rnx-kit/metro-resolver-symlinks": "^0.2.5",
|
||||
"@types/react": "catalog:rn",
|
||||
"@types/react": "^19.1.0",
|
||||
"eslint": "^8.19.0",
|
||||
"pod-install": "^0.3.5",
|
||||
"prettier": "2.8.8",
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.121
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ac3e694]
|
||||
- Updated dependencies [6dbb053]
|
||||
- Updated dependencies [1a182f0]
|
||||
- jazz-tools@0.17.8
|
||||
|
||||
## 0.0.120
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-tools@0.17.7
|
||||
|
||||
## 0.0.119
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [82de51c]
|
||||
- Updated dependencies [694b168]
|
||||
- jazz-tools@0.17.6
|
||||
|
||||
## 0.0.118
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5963658]
|
||||
- jazz-tools@0.17.5
|
||||
|
||||
## 0.0.117
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7dd3d00]
|
||||
- jazz-tools@0.17.4
|
||||
|
||||
## 0.0.116
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-tools@0.17.3
|
||||
|
||||
## 0.0.115
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.115",
|
||||
"version": "0.0.121",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -12,24 +12,24 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"hash-slash": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.536.0",
|
||||
"quint-ui": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react-swc": "^3.10.1",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ChatScreen } from "./chatScreen.tsx";
|
||||
import { Chat } from "./schema.ts";
|
||||
import { ThemeProvider } from "./themeProvider.tsx";
|
||||
import { AppContainer, TopBar } from "./ui.tsx";
|
||||
import { Button } from "quint-ui";
|
||||
|
||||
export function App() {
|
||||
const { me, logOut } = useAccount();
|
||||
@@ -40,11 +39,7 @@ export function App() {
|
||||
}}
|
||||
placeholder="Set username"
|
||||
/>
|
||||
{!inIframe && (
|
||||
<Button intent="danger" onClick={logOut}>
|
||||
Log out
|
||||
</Button>
|
||||
)}
|
||||
{!inIframe && <button onClick={logOut}>Log out</button>}
|
||||
</TopBar>
|
||||
{router.route({
|
||||
"/": () => createChat() as never,
|
||||
|
||||
@@ -1,3 +1,54 @@
|
||||
@import "quint-ui/styles.css";
|
||||
/* We need to tell tailwind to detect classes from quint components */
|
||||
@source "../node_modules/quint-ui";
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom stone color palette */
|
||||
@theme {
|
||||
--color-stone-50: oklch(0.988281 0.002 75);
|
||||
--color-stone-75: oklch(0.980563 0.002 75);
|
||||
--color-stone-100: oklch(0.964844 0.002 75);
|
||||
--color-stone-200: oklch(0.917969 0.002 75);
|
||||
--color-stone-300: oklch(0.853516 0.002 75);
|
||||
--color-stone-400: oklch(0.789063 0.002 75);
|
||||
--color-stone-500: oklch(0.726563 0.002 75);
|
||||
--color-stone-600: oklch(0.613281 0.002 75);
|
||||
--color-stone-700: oklch(0.523438 0.002 75);
|
||||
--color-stone-800: oklch(0.412109 0.002 75);
|
||||
--color-stone-900: oklch(0.302734 0.002 75);
|
||||
--color-stone-925: oklch(0.22 0.002 75);
|
||||
--color-stone-950: oklch(0.193359 0.002 75);
|
||||
|
||||
/* Blue color overrides */
|
||||
--color-blue-50: #eef2ff;
|
||||
--color-blue-100: #e0e7ff;
|
||||
--color-blue-200: #c7d2fe;
|
||||
--color-blue-300: #a5b4fc;
|
||||
--color-blue-400: #818cf8;
|
||||
--color-blue-500: #5870f1;
|
||||
--color-blue-600: #5145cd;
|
||||
--color-blue-700: #4338ca;
|
||||
--color-blue-800: #3730a3;
|
||||
--color-blue-900: #312e81;
|
||||
--color-blue-950: #1e1b4b;
|
||||
--color-blue: #146aff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--border-default: var(--color-stone-200);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--border-default: var(--color-stone-900);
|
||||
}
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--border-default, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { CoPlainText, ImageDefinition } from "jazz-tools";
|
||||
import { Image } from "jazz-tools/react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { Button, cn, Input, Label } from "quint-ui";
|
||||
import { useId, useRef } from "react";
|
||||
|
||||
export function AppContainer(props: { children: React.ReactNode }) {
|
||||
@@ -57,7 +57,7 @@ export function BubbleBody(props: {
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className={clsx(
|
||||
"line-clamp-10 text-ellipsis whitespace-pre-wrap",
|
||||
"rounded-2xl overflow-hidden max-w-[calc(100%-5rem)] shadow-sm p-1",
|
||||
props.fromMe
|
||||
@@ -75,7 +75,9 @@ export function BubbleText(props: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<p className={cn("px-2 leading-relaxed", props.className)}>{props.text}</p>
|
||||
<p className={clsx("px-2 leading-relaxed", props.className)}>
|
||||
{props.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,26 +121,25 @@ export function ImageInput({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Send image"
|
||||
title="Send image"
|
||||
onClick={onUploadClick}
|
||||
intent="strong"
|
||||
className="text-stone-500 p-1.5 rounded-full hover:bg-stone-100 hover:text-stone-800 dark:hover:bg-stone-800 dark:hover:text-stone-200 transition-colors"
|
||||
>
|
||||
<ImageIcon size={24} strokeWidth={1.5} />
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Label isHiddenVisually htmlFor={inputRef.current?.id}>
|
||||
<label className="sr-only">
|
||||
Image
|
||||
</Label>
|
||||
<Input
|
||||
id={inputRef.current?.id}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
onChange={onImageChange}
|
||||
className="w-32"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
onChange={onImageChange}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -148,11 +149,12 @@ export function TextInput(props: { onSubmit: (text: string) => void }) {
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<Label isHiddenVisually htmlFor={inputId}>
|
||||
<label className="sr-only" htmlFor={inputId}>
|
||||
Type a message and press Enter
|
||||
</Label>
|
||||
<Input
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
className="rounded-full py-1 px-3 border block w-full placeholder:text-stone-500 dark:bg-stone-925 dark:text-white dark:border-stone-900"
|
||||
placeholder="Type a message and press Enter"
|
||||
maxLength={2048}
|
||||
onKeyDown={({ key, currentTarget: input }) => {
|
||||
|
||||
@@ -14,22 +14,22 @@
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.11",
|
||||
"@clerk/clerk-expo": "^2.13.1",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"expo": "catalog:expo",
|
||||
"expo-crypto": "catalog:expo",
|
||||
"expo-linking": "catalog:expo",
|
||||
"expo-secure-store": "catalog:expo",
|
||||
"expo-sqlite": "catalog:expo",
|
||||
"expo-web-browser": "catalog:expo",
|
||||
"expo": "54.0.0-canary-20250701-6a945c5",
|
||||
"expo-crypto": "~14.1.5",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-sqlite": "~15.2.10",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:expo",
|
||||
"react-native": "catalog:expo",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.80.0",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"readable-stream": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "catalog:expo",
|
||||
"@types/react": "^19.0.10",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,17 @@
|
||||
"dependencies": {
|
||||
"@clerk/clerk-react": "^5.4.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { clerk } from "@clerk/testing/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("login & expiration", async ({ page, context }) => {
|
||||
// Flaky on CI
|
||||
test.skip("login & expiration", async ({ page, context }) => {
|
||||
// Clear cookies first
|
||||
await context.clearCookies();
|
||||
|
||||
|
||||
@@ -11,20 +11,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,22 +12,22 @@
|
||||
"dependencies": {
|
||||
"hash-slash": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default",
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^4.1.10"
|
||||
}
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
"cojson-transport-ws": "workspace:*",
|
||||
"hash-slash": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-use": "^17.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react-swc": "^3.10.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,22 @@
|
||||
"dependencies": {
|
||||
"@react-spring/web": "^9.7.5",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.4.40",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
"dependencies": {
|
||||
"@clerk/clerk-react": "^5.4.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwindcss": "^4.1.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0"
|
||||
@@ -32,13 +32,13 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react-swc": "^3.10.1",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default",
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { co, z } from "jazz-tools";
|
||||
import { co, Group, z } from "jazz-tools";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
@@ -70,15 +70,22 @@ export const MusicaAccountRoot = co.map({
|
||||
activePlaylist: Playlist,
|
||||
|
||||
exampleDataLoaded: z.optional(z.boolean()),
|
||||
accountSetupCompleted: z.optional(z.boolean()),
|
||||
});
|
||||
export type MusicaAccountRoot = co.loaded<typeof MusicaAccountRoot>;
|
||||
|
||||
export const MusicaAccountProfile = co.profile({
|
||||
avatar: co.optional(co.image()),
|
||||
});
|
||||
export type MusicaAccountProfile = co.loaded<typeof MusicaAccountProfile>;
|
||||
|
||||
export const MusicaAccount = co
|
||||
.account({
|
||||
/** the default user profile with a name */
|
||||
profile: co.profile(),
|
||||
profile: MusicaAccountProfile,
|
||||
root: MusicaAccountRoot,
|
||||
})
|
||||
.withMigration((account) => {
|
||||
.withMigration(async (account) => {
|
||||
/**
|
||||
* 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.
|
||||
@@ -97,6 +104,32 @@ export const MusicaAccount = co
|
||||
exampleDataLoaded: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (account.profile === undefined) {
|
||||
account.profile = MusicaAccountProfile.create({
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
|
||||
// Load the profile and root in memory, to have them ready
|
||||
const { profile, root } = await account.ensureLoaded({
|
||||
resolve: {
|
||||
profile: {
|
||||
avatar: true,
|
||||
},
|
||||
root: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up the private avatars (were created using the account as owner)
|
||||
if (profile.avatar) {
|
||||
const group = profile.avatar._owner.castAs(Group);
|
||||
|
||||
if (group.getRoleOf("everyone") !== "reader") {
|
||||
root.accountSetupCompleted = false;
|
||||
profile.avatar = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
export type MusicaAccount = co.loaded<typeof MusicaAccount>;
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ import { RouterProvider, createHashRouter } from "react-router-dom";
|
||||
import { HomePage } from "./3_HomePage";
|
||||
import { useMediaPlayer } from "./5_useMediaPlayer";
|
||||
import { InvitePage } from "./6_InvitePage";
|
||||
import { WelcomeScreen } from "./components/WelcomeScreen";
|
||||
import "./index.css";
|
||||
|
||||
import { MusicaAccount } from "@/1_schema";
|
||||
import { apiKey } from "@/apiKey.ts";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
import { JazzReactProvider, useAccount } from "jazz-tools/react";
|
||||
import { onAnonymousAccountDiscarded } from "./4_actions";
|
||||
import { KeyboardListener } from "./components/PlayerControls";
|
||||
import { usePrepareAppState } from "./lib/usePrepareAppState";
|
||||
@@ -28,11 +29,22 @@ import { usePrepareAppState } from "./lib/usePrepareAppState";
|
||||
* `<JazzReactProvider/>` also runs our account migration
|
||||
*/
|
||||
|
||||
function Main() {
|
||||
const mediaPlayer = useMediaPlayer();
|
||||
function AppContent({
|
||||
mediaPlayer,
|
||||
}: {
|
||||
mediaPlayer: ReturnType<typeof useMediaPlayer>;
|
||||
}) {
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { root: true },
|
||||
});
|
||||
|
||||
const isReady = usePrepareAppState(mediaPlayer);
|
||||
|
||||
// Show welcome screen if account setup is not completed
|
||||
if (me && !me.root.accountSetupCompleted) {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
@@ -59,6 +71,17 @@ function Main() {
|
||||
);
|
||||
}
|
||||
|
||||
function Main() {
|
||||
const mediaPlayer = useMediaPlayer();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppContent mediaPlayer={mediaPlayer} />
|
||||
<JazzInspector />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const peer =
|
||||
(new URL(window.location.href).searchParams.get(
|
||||
"peer",
|
||||
|
||||
@@ -12,11 +12,13 @@ import { MediaPlayer } from "./5_useMediaPlayer";
|
||||
import { FileUploadButton } from "./components/FileUploadButton";
|
||||
import { MusicTrackRow } from "./components/MusicTrackRow";
|
||||
import { PlayerControls } from "./components/PlayerControls";
|
||||
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
|
||||
import { EditPlaylistModal } from "./components/EditPlaylistModal";
|
||||
import { PlaylistMembers } from "./components/PlaylistMembers";
|
||||
import { SidePanel } from "./components/SidePanel";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { SidebarInset, SidebarTrigger } from "./components/ui/sidebar";
|
||||
import { usePlayState } from "./lib/audio/usePlayState";
|
||||
import { useState } from "react";
|
||||
|
||||
export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
/**
|
||||
@@ -30,6 +32,7 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
const playState = usePlayState();
|
||||
const isPlaying = playState.value === "play";
|
||||
const { toast } = useToast();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
async function handleFileLoad(files: FileList) {
|
||||
/**
|
||||
@@ -50,6 +53,7 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
},
|
||||
});
|
||||
|
||||
const membersIds = playlist?._owner.members.map((member) => member.id);
|
||||
const isRootPlaylist = !params.playlistId;
|
||||
const isPlaylistOwner = playlist?._owner.myRole() === "admin";
|
||||
const isActivePlaylist = playlistId === me?.root.activePlaylist?.id;
|
||||
@@ -66,6 +70,10 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
return (
|
||||
@@ -74,11 +82,17 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
<SidePanel />
|
||||
<main className="flex-1 px-2 py-4 md:px-6 overflow-y-auto overflow-x-hidden relative sm:h-[calc(100vh-80px)] bg-white h-[calc(100vh-165px)]">
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-4 pl-1 md:pl-10 pr-2 md:pr-0 mt-2 md:mt-0 w-full">
|
||||
{isRootPlaylist ? (
|
||||
<h1 className="text-2xl font-bold text-blue-800">All tracks</h1>
|
||||
) : (
|
||||
<PlaylistTitleInput className="w-full" playlistId={playlistId} />
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-2xl font-bold text-blue-800">
|
||||
{playlist?.title}
|
||||
</h1>
|
||||
{membersIds && <PlaylistMembers memberIds={membersIds} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-4">
|
||||
{isRootPlaylist && (
|
||||
@@ -89,9 +103,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
</>
|
||||
)}
|
||||
{!isRootPlaylist && isAuthenticated && (
|
||||
<Button onClick={handlePlaylistShareClick}>
|
||||
Share playlist
|
||||
</Button>
|
||||
<>
|
||||
<Button onClick={handleEditClick} variant="outline">
|
||||
Edit
|
||||
</Button>
|
||||
<Button onClick={handlePlaylistShareClick}>Share</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +135,13 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
</main>
|
||||
<PlayerControls mediaPlayer={mediaPlayer} />
|
||||
</div>
|
||||
|
||||
{/* Playlist Title Edit Modal */}
|
||||
<EditPlaylistModal
|
||||
playlistId={playlistId}
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setIsEditModalOpen(false)}
|
||||
/>
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function uploadMusicTracks(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNewPlaylist() {
|
||||
export async function createNewPlaylist(title: string = "New Playlist") {
|
||||
const { root } = await MusicaAccount.getMe().ensureLoaded({
|
||||
resolve: {
|
||||
root: {
|
||||
@@ -69,7 +69,7 @@ export async function createNewPlaylist() {
|
||||
});
|
||||
|
||||
const playlist = Playlist.create({
|
||||
title: "New Playlist",
|
||||
title,
|
||||
tracks: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useMediaPlayer() {
|
||||
// Reference used to avoid out-of-order track loads
|
||||
const lastLoadedTrackId = useRef<string | null>(null);
|
||||
|
||||
async function loadTrack(track: MusicTrack) {
|
||||
async function loadTrack(track: MusicTrack, autoPlay = true) {
|
||||
lastLoadedTrackId.current = track.id;
|
||||
audioManager.unloadCurrentAudio();
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useMediaPlayer() {
|
||||
return;
|
||||
}
|
||||
|
||||
await playMedia(file);
|
||||
await playMedia(file, autoPlay);
|
||||
|
||||
setLoading(null);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAccount, usePasskeyAuth } from "jazz-tools/react";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -18,7 +16,6 @@ interface AuthModalProps {
|
||||
}
|
||||
|
||||
export function AuthModal({ open, onOpenChange }: AuthModalProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [isSignUp, setIsSignUp] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -31,6 +28,7 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
|
||||
},
|
||||
},
|
||||
},
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +46,7 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
|
||||
|
||||
try {
|
||||
if (isSignUp) {
|
||||
await auth.signUp(username);
|
||||
await auth.signUp(me?.profile.name || "");
|
||||
} else {
|
||||
await auth.logIn();
|
||||
}
|
||||
@@ -84,18 +82,6 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isSignUp && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-sm text-red-500">{error}</div>}
|
||||
{shouldShowTransferRootPlaylist && (
|
||||
<div className="text-sm text-red-500">
|
||||
|
||||
105
examples/music-player/src/components/CreatePlaylistModal.tsx
Normal file
105
examples/music-player/src/components/CreatePlaylistModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createNewPlaylist } from "@/4_actions";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
interface CreatePlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onPlaylistCreated: (playlistId: string) => void;
|
||||
}
|
||||
|
||||
export function CreatePlaylistModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onPlaylistCreated,
|
||||
}: CreatePlaylistModalProps) {
|
||||
const [playlistTitle, setPlaylistTitle] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
function handleTitleChange(evt: React.ChangeEvent<HTMLInputElement>) {
|
||||
setPlaylistTitle(evt.target.value);
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!playlistTitle.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const playlist = await createNewPlaylist(playlistTitle.trim());
|
||||
onPlaylistCreated(playlist.id);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create playlist:", error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setPlaylistTitle("");
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleKeyDown(evt: React.KeyboardEvent) {
|
||||
if (evt.key === "Enter") {
|
||||
handleCreate();
|
||||
} else if (evt.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Create New Playlist
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">Give your new playlist a name</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="playlist-title"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Playlist Title
|
||||
</Label>
|
||||
<Input
|
||||
id="playlist-title"
|
||||
value={playlistTitle}
|
||||
onChange={handleTitleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter playlist title"
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2"
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!playlistTitle.trim() || isCreating}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create Playlist"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
examples/music-player/src/components/EditPlaylistModal.tsx
Normal file
104
examples/music-player/src/components/EditPlaylistModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Playlist } from "@/1_schema";
|
||||
import { updatePlaylistTitle } from "@/4_actions";
|
||||
import { useCoState } from "jazz-tools/react";
|
||||
import { ChangeEvent, useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
interface EditPlaylistModalProps {
|
||||
playlistId: string | undefined;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditPlaylistModal({
|
||||
playlistId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: EditPlaylistModalProps) {
|
||||
const playlist = useCoState(Playlist, playlistId);
|
||||
const [localPlaylistTitle, setLocalPlaylistTitle] = useState("");
|
||||
|
||||
// Reset local title when modal opens or playlist changes
|
||||
useEffect(() => {
|
||||
if (isOpen && playlist) {
|
||||
setLocalPlaylistTitle(playlist.title ?? "");
|
||||
}
|
||||
}, [isOpen, playlist]);
|
||||
|
||||
function handleTitleChange(evt: ChangeEvent<HTMLInputElement>) {
|
||||
setLocalPlaylistTitle(evt.target.value);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (playlist && localPlaylistTitle.trim()) {
|
||||
updatePlaylistTitle(playlist, localPlaylistTitle.trim());
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setLocalPlaylistTitle(playlist?.title ?? "");
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleKeyDown(evt: React.KeyboardEvent) {
|
||||
if (evt.key === "Enter") {
|
||||
handleSave();
|
||||
} else if (evt.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Edit Playlist
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="playlist-title"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Playlist Title
|
||||
</Label>
|
||||
<Input
|
||||
id="playlist-title"
|
||||
value={localPlaylistTitle}
|
||||
onChange={handleTitleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter playlist title"
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!localPlaylistTitle.trim()}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
examples/music-player/src/components/Member.tsx
Normal file
83
examples/music-player/src/components/Member.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useCoState } from "jazz-tools/react";
|
||||
import { MusicaAccount } from "@/1_schema";
|
||||
import { Image } from "jazz-tools/react";
|
||||
|
||||
interface MemberProps {
|
||||
accountId: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showTooltip?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Member({
|
||||
accountId,
|
||||
size = "md",
|
||||
showTooltip = true,
|
||||
className = "",
|
||||
}: MemberProps) {
|
||||
const account = useCoState(MusicaAccount, accountId, {
|
||||
resolve: { profile: true },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full bg-gray-200 border-2 border-white flex items-center justify-center ${getSizeClasses(size)} ${className}`}
|
||||
>
|
||||
<span className="text-gray-500 text-xs">👤</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avatar = account.profile?.avatar;
|
||||
const name = account.profile?.name || "Unknown User";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full border-2 border-white overflow-hidden ${getSizeClasses(size)} ${className}`}
|
||||
title={showTooltip ? name : undefined}
|
||||
>
|
||||
{avatar ? (
|
||||
<Image
|
||||
imageId={avatar.id}
|
||||
width={getSizePx(size)}
|
||||
height={getSizePx(size)}
|
||||
alt={`${name}'s avatar`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSizeClasses(size: "sm" | "md" | "lg"): string {
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return "w-6 h-6";
|
||||
case "md":
|
||||
return "w-8 h-8";
|
||||
case "lg":
|
||||
return "w-10 h-10";
|
||||
default:
|
||||
return "w-8 h-8";
|
||||
}
|
||||
}
|
||||
|
||||
function getSizePx(size: "sm" | "md" | "lg"): number {
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return 24;
|
||||
case "md":
|
||||
return 32;
|
||||
case "lg":
|
||||
return 40;
|
||||
default:
|
||||
return 32;
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export function MusicTrackRow({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
|
||||
{playlists.map((playlist, playlistIndex) => (
|
||||
{playlists.filter(Boolean).map((playlist, playlistIndex) => (
|
||||
<Fragment key={playlistIndex}>
|
||||
{isPartOfThePlaylist(trackId, playlist) ? (
|
||||
<DropdownMenuItem
|
||||
|
||||
30
examples/music-player/src/components/PlaylistMembers.tsx
Normal file
30
examples/music-player/src/components/PlaylistMembers.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Member } from "./Member";
|
||||
|
||||
interface PlaylistMembersProps {
|
||||
memberIds: string[];
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlaylistMembers({
|
||||
memberIds,
|
||||
size = "md",
|
||||
className = "",
|
||||
}: PlaylistMembersProps) {
|
||||
if (!memberIds || memberIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-2 ${className}`}>
|
||||
<div className="flex -space-x-2">
|
||||
{memberIds.map((memberId) => (
|
||||
<Member
|
||||
key={memberId}
|
||||
accountId={memberId}
|
||||
size={size}
|
||||
showTooltip={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
examples/music-player/src/components/ProfileForm.tsx
Normal file
213
examples/music-player/src/components/ProfileForm.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Image, useAccount } from "jazz-tools/react";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
import { MusicaAccount } from "../1_schema";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { Group } from "jazz-tools";
|
||||
|
||||
interface ProfileFormProps {
|
||||
onSubmit?: (data: { username: string; avatar?: any }) => void;
|
||||
submitButtonText?: string;
|
||||
showHeader?: boolean;
|
||||
headerTitle?: string;
|
||||
headerDescription?: string;
|
||||
initialUsername?: string;
|
||||
initialAvatar?: any;
|
||||
onCancel?: () => void;
|
||||
showCancelButton?: boolean;
|
||||
cancelButtonText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProfileForm({
|
||||
onSubmit,
|
||||
submitButtonText = "Save Changes",
|
||||
showHeader = false,
|
||||
headerTitle = "Profile Settings",
|
||||
headerDescription = "Update your profile information",
|
||||
initialUsername = "",
|
||||
initialAvatar,
|
||||
onCancel,
|
||||
showCancelButton = false,
|
||||
cancelButtonText = "Cancel",
|
||||
className = "",
|
||||
}: ProfileFormProps) {
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { profile: true, root: true },
|
||||
});
|
||||
|
||||
const [username, setUsername] = useState(
|
||||
initialUsername || me?.profile?.name || "",
|
||||
);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
const handleAvatarUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Create image using the Image API from jazz-tools/media
|
||||
const image = await createImage(file, {
|
||||
owner: Group.create().makePublic(),
|
||||
maxSize: 256, // Good size for avatars
|
||||
placeholder: "blur",
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
// Update the profile with the new avatar
|
||||
me.profile.avatar = image;
|
||||
} catch (error) {
|
||||
console.error("Failed to upload avatar:", error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username.trim()) return;
|
||||
|
||||
// Update username
|
||||
me.profile.name = username.trim();
|
||||
|
||||
// Call custom onSubmit if provided
|
||||
if (onSubmit) {
|
||||
onSubmit({ username: username.trim(), avatar: me.profile.avatar });
|
||||
}
|
||||
};
|
||||
|
||||
const currentAvatar = initialAvatar || me.profile.avatar;
|
||||
const canSubmit = username.trim();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{showHeader && (
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{headerTitle}
|
||||
</h1>
|
||||
<p className="text-gray-600">{headerDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Avatar Section */}
|
||||
<div className="space-y-3">
|
||||
<Label
|
||||
htmlFor="avatar"
|
||||
className="text-sm font-medium text-gray-700 sr-only"
|
||||
>
|
||||
Profile Picture
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
{/* Current Avatar Display */}
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden border-4 border-white shadow-lg">
|
||||
{currentAvatar ? (
|
||||
<Image
|
||||
imageId={currentAvatar.id}
|
||||
width={96}
|
||||
height={96}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-2xl">👤</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Overlay */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="absolute -bottom-1 -right-1 w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white hover:bg-blue-700 disabled:opacity-50 transition-colors cursor-pointer"
|
||||
title="Change avatar"
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<span className="text-sm">📷</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="avatar"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
Click the camera icon to upload a profile picture
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Username Section */}
|
||||
<div className="space-y-3">
|
||||
<Label
|
||||
htmlFor="username"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full"
|
||||
maxLength={30}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
This will be displayed to other users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3">
|
||||
{showCancelButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
>
|
||||
{cancelButtonText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className={`${showCancelButton ? "flex-1" : "w-full"} bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
size="lg"
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
examples/music-player/src/components/ProfileSettings.tsx
Normal file
117
examples/music-player/src/components/ProfileSettings.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { MusicaAccount } from "../1_schema";
|
||||
import { ProfileForm } from "./ProfileForm";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function ProfileSettings() {
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { profile: true, root: true },
|
||||
});
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
const handleSaveProfile = (data: { username: string; avatar?: any }) => {
|
||||
// Profile is automatically updated by the ProfileForm component
|
||||
// You can add additional logic here if needed
|
||||
console.log("Profile updated:", data);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<ProfileForm
|
||||
onSubmit={handleSaveProfile}
|
||||
onCancel={handleCancel}
|
||||
showCancelButton={true}
|
||||
submitButtonText="Save Changes"
|
||||
showHeader={true}
|
||||
headerTitle="Edit Profile"
|
||||
headerDescription="Update your profile information"
|
||||
initialUsername={me.profile.name || ""}
|
||||
initialAvatar={me.profile.avatar}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
{/* Profile Display */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Profile Settings
|
||||
</h1>
|
||||
|
||||
{/* Avatar Display */}
|
||||
<div className="mb-6">
|
||||
<div className="w-32 h-32 rounded-full overflow-hidden border-4 border-white shadow-lg mx-auto">
|
||||
{me.profile.avatar ? (
|
||||
<img
|
||||
src={`/api/images/${me.profile.avatar.id}`}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-4xl">👤</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username Display */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{me.profile.name || "No username set"}
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{me.profile.name
|
||||
? "Your display name"
|
||||
: "Set a username to get started"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Edit Button */}
|
||||
<Button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
size="lg"
|
||||
>
|
||||
Edit Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Additional Profile Information */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Account Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Account ID:</span>
|
||||
<span className="text-sm font-mono text-gray-800">{me.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">Setup Completed:</span>
|
||||
<span
|
||||
className={`text-sm ${me.root.accountSetupCompleted ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{me.root.accountSetupCompleted ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MusicaAccount } from "@/1_schema";
|
||||
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
|
||||
import { deletePlaylist } from "@/4_actions";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { Home, Music, Plus, Trash2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { AuthButton } from "./AuthButton";
|
||||
import { CreatePlaylistModal } from "./CreatePlaylistModal";
|
||||
|
||||
export function SidePanel() {
|
||||
const { playlistId } = useParams();
|
||||
@@ -23,6 +25,7 @@ export function SidePanel() {
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { root: { playlists: { $each: true } } },
|
||||
});
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
function handleAllTracksClick() {
|
||||
navigate(`/`);
|
||||
@@ -39,92 +42,104 @@ export function SidePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePlaylist() {
|
||||
const playlist = await createNewPlaylist();
|
||||
navigate(`/playlist/${playlist.id}`);
|
||||
function handleCreatePlaylistClick() {
|
||||
setIsCreateModalOpen(true);
|
||||
}
|
||||
|
||||
function handlePlaylistCreated(playlistId: string) {
|
||||
navigate(`/playlist/${playlistId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="flex items-center gap-2">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
|
||||
<svg
|
||||
className="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 18V5l12-2v13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 15H3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zM18 13h-3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Music Player</span>
|
||||
</div>
|
||||
<AuthButton />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={handleAllTracksClick}>
|
||||
<Home className="size-4" />
|
||||
<span>Go to all tracks</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Playlists</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={handleCreatePlaylist}>
|
||||
<Plus className="size-4" />
|
||||
<span>Add a new playlist</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{me?.root.playlists.map((playlist) => (
|
||||
<SidebarMenuItem key={playlist.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handlePlaylistClick(playlist.id)}
|
||||
isActive={playlist.id === playlistId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="size-4" />
|
||||
<span>{playlist.title}</span>
|
||||
</div>
|
||||
<>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="flex items-center gap-2">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
|
||||
<svg
|
||||
className="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 18V5l12-2v13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 15H3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zM18 13h-3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Music Player</span>
|
||||
</div>
|
||||
<AuthButton />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={handleAllTracksClick}>
|
||||
<Home className="size-4" />
|
||||
<span>Go to all tracks</span>
|
||||
</SidebarMenuButton>
|
||||
{playlist.id === playlistId && (
|
||||
<SidebarMenuAction
|
||||
onClick={() => handleDeletePlaylist(playlist.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Delete {playlist.title}</span>
|
||||
</SidebarMenuAction>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Playlists</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={handleCreatePlaylistClick}>
|
||||
<Plus className="size-4" />
|
||||
<span>Add a new playlist</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{me?.root.playlists.map((playlist) => (
|
||||
<SidebarMenuItem key={playlist.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handlePlaylistClick(playlist.id)}
|
||||
isActive={playlist.id === playlistId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="size-4" />
|
||||
<span>{playlist.title}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
{playlist.id === playlistId && (
|
||||
<SidebarMenuAction
|
||||
onClick={() => handleDeletePlaylist(playlist.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Delete {playlist.title}</span>
|
||||
</SidebarMenuAction>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
{/* Create Playlist Modal */}
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onPlaylistCreated={handlePlaylistCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
95
examples/music-player/src/components/WelcomeScreen.tsx
Normal file
95
examples/music-player/src/components/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useAccount, usePasskeyAuth } from "jazz-tools/react";
|
||||
import { MusicaAccount } from "../1_schema";
|
||||
import { ProfileForm } from "./ProfileForm";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { profile: true, root: true },
|
||||
});
|
||||
|
||||
const auth = usePasskeyAuth({
|
||||
appName: "Jazz Music Player",
|
||||
});
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
const handleCompleteSetup = () => {
|
||||
// Mark account setup as completed
|
||||
me.root.accountSetupCompleted = true;
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
auth.logIn();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-auto min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-16 items-center">
|
||||
{/* Form Panel */}
|
||||
<div className="w-full max-w-md bg-white rounded-lg shadow-xl p-8">
|
||||
<ProfileForm
|
||||
onSubmit={handleCompleteSetup}
|
||||
submitButtonText="Continue"
|
||||
showHeader={true}
|
||||
headerTitle="Welcome to Music Player! 🎵"
|
||||
headerDescription="Let's set up your profile to get started"
|
||||
initialUsername={me?.profile?.name || ""}
|
||||
initialAvatar={me?.profile?.avatar}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:hidden pt-4 flex justify-end items-center w-full gap-2">
|
||||
<div className="text-sm font-semibold text-gray-600">
|
||||
Already a user?
|
||||
</div>
|
||||
<Button onClick={handleLogin} size="sm">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Title Section - Hidden on mobile, shown on right side for larger screens */}
|
||||
<div className="hidden lg:flex flex-col justify-center items-start max-w-md">
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 leading-tight">
|
||||
Your Music at your fingertips.
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-xl lg:text-2xl text-gray-700 font-medium">
|
||||
Offline, Collaborative, Fast
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500 font-medium">
|
||||
Powered by
|
||||
</span>
|
||||
<a
|
||||
href="https://jazz.tools"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-lg font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
Jazz
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<div className="pt-4">
|
||||
<p className="text-sm font-semibold text-gray-600 mb-2">
|
||||
Already a user?
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 text-lg font-medium rounded-lg shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
size="lg"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -294,7 +294,7 @@ const SidebarTrigger = React.forwardRef<
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<PanelLeft className="size-4" />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function usePlayMedia() {
|
||||
|
||||
const previousMediaLoad = useRef<Promise<unknown> | undefined>(undefined);
|
||||
|
||||
async function playMedia(file: Blob) {
|
||||
async function playMedia(file: Blob, autoPlay = true) {
|
||||
// Wait for the previous load to finish
|
||||
// to avoid to incur into concurrency issues
|
||||
await previousMediaLoad.current;
|
||||
@@ -17,7 +17,9 @@ export function usePlayMedia() {
|
||||
|
||||
await promise;
|
||||
|
||||
audioManager.play();
|
||||
if (autoPlay) {
|
||||
audioManager.play();
|
||||
}
|
||||
}
|
||||
|
||||
return playMedia;
|
||||
|
||||
@@ -34,7 +34,7 @@ async function loadInitialData(mediaPlayer: MediaPlayer) {
|
||||
|
||||
// Load the active track in the AudioManager
|
||||
if (me.root.activeTrack) {
|
||||
mediaPlayer.loadTrack(me.root.activeTrack);
|
||||
mediaPlayer.loadTrack(me.root.activeTrack, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,10 @@ test("sign up and log out", async ({ page: marioPage }) => {
|
||||
|
||||
const marioHome = new HomePage(marioPage);
|
||||
|
||||
await marioHome.signUp("Mario");
|
||||
await marioHome.fillUsername("Mario");
|
||||
await marioPage.keyboard.press("Enter");
|
||||
|
||||
await marioHome.signUp();
|
||||
|
||||
await marioHome.logoutButton.waitFor({
|
||||
state: "visible",
|
||||
|
||||
@@ -47,12 +47,14 @@ test("create a new playlist and share", async ({
|
||||
|
||||
const marioHome = new HomePage(marioPage);
|
||||
|
||||
await marioHome.fillUsername("Mario");
|
||||
await marioPage.keyboard.press("Enter");
|
||||
|
||||
// The example song should be loaded
|
||||
await marioHome.expectMusicTrack("Example song");
|
||||
await marioHome.editTrackTitle("Example song", "Super Mario World");
|
||||
|
||||
await marioHome.createPlaylist();
|
||||
await marioHome.editPlaylistTitle("Save the princess");
|
||||
await marioHome.createPlaylist("Save the princess");
|
||||
|
||||
await marioHome.navigateToPlaylist("All tracks");
|
||||
await marioHome.addTrackToPlaylist("Super Mario World", "Save the princess");
|
||||
@@ -60,7 +62,7 @@ test("create a new playlist and share", async ({
|
||||
await marioHome.navigateToPlaylist("Save the princess");
|
||||
await marioHome.expectMusicTrack("Super Mario World");
|
||||
|
||||
await marioHome.signUp("Mario");
|
||||
await marioHome.signUp();
|
||||
|
||||
const url = await marioHome.getShareLink();
|
||||
|
||||
@@ -74,7 +76,10 @@ test("create a new playlist and share", async ({
|
||||
|
||||
const luigiHome = new HomePage(luigiPage);
|
||||
|
||||
await luigiHome.signUp("Luigi");
|
||||
await luigiHome.fillUsername("Luigi");
|
||||
await luigiPage.keyboard.press("Enter");
|
||||
|
||||
await luigiHome.signUp();
|
||||
|
||||
await luigiPage.goto(url);
|
||||
|
||||
@@ -90,15 +95,18 @@ test("create a new playlist, share, then remove track", async ({
|
||||
// Create playlist with a song and share
|
||||
await marioPage.goto("/");
|
||||
const marioHome = new HomePage(marioPage);
|
||||
|
||||
await marioHome.fillUsername("Mario");
|
||||
await marioPage.keyboard.press("Enter");
|
||||
|
||||
await marioHome.expectMusicTrack("Example song");
|
||||
await marioHome.editTrackTitle("Example song", "Super Mario World");
|
||||
await marioHome.createPlaylist();
|
||||
await marioHome.editPlaylistTitle("Save the princess");
|
||||
await marioHome.createPlaylist("Save the princess");
|
||||
await marioHome.navigateToPlaylist("All tracks");
|
||||
await marioHome.addTrackToPlaylist("Super Mario World", "Save the princess");
|
||||
await marioHome.navigateToPlaylist("Save the princess");
|
||||
await marioHome.expectMusicTrack("Super Mario World");
|
||||
await marioHome.signUp("Mario");
|
||||
await marioHome.signUp();
|
||||
const url = await marioHome.getShareLink();
|
||||
|
||||
await sleep(4000); // Wait for the sync to complete
|
||||
@@ -109,7 +117,12 @@ test("create a new playlist, share, then remove track", async ({
|
||||
const luigiPage = await luigiContext.newPage();
|
||||
await luigiPage.goto("/");
|
||||
const luigiHome = new HomePage(luigiPage);
|
||||
await luigiHome.signUp("Luigi");
|
||||
|
||||
await luigiHome.fillUsername("Luigi");
|
||||
await luigiPage.keyboard.press("Enter");
|
||||
|
||||
await luigiHome.signUp();
|
||||
|
||||
await luigiPage.goto(url);
|
||||
await luigiHome.expectMusicTrack("Super Mario World");
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export class HomePage {
|
||||
name: "Sign out",
|
||||
});
|
||||
|
||||
async fillUsername(username: string) {
|
||||
await this.page.getByRole("textbox", { name: "Username" }).fill(username);
|
||||
}
|
||||
|
||||
async expectActiveTrackPlaying() {
|
||||
await expect(
|
||||
this.page.getByRole("button", {
|
||||
@@ -71,12 +75,10 @@ export class HomePage {
|
||||
await this.page.getByRole("button", { name: "Save" }).click();
|
||||
}
|
||||
|
||||
async createPlaylist() {
|
||||
async createPlaylist(playlistTitle: string) {
|
||||
await this.newPlaylistButton.click();
|
||||
}
|
||||
|
||||
async editPlaylistTitle(playlistTitle: string) {
|
||||
await this.playlistTitleInput.fill(playlistTitle);
|
||||
await this.page.getByRole("button", { name: "Create Playlist" }).click();
|
||||
}
|
||||
|
||||
async navigateToPlaylist(playlistTitle: string) {
|
||||
@@ -98,7 +100,7 @@ export class HomePage {
|
||||
async getShareLink() {
|
||||
await this.page
|
||||
.getByRole("button", {
|
||||
name: "Share playlist",
|
||||
name: "Share",
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -139,9 +141,8 @@ export class HomePage {
|
||||
.click();
|
||||
}
|
||||
|
||||
async signUp(name: string) {
|
||||
async signUp() {
|
||||
await this.page.getByRole("button", { name: "Sign up" }).click();
|
||||
await this.page.getByRole("textbox", { name: "Username" }).fill(name);
|
||||
await this.page
|
||||
.getByRole("button", { name: "Sign up with passkey" })
|
||||
.click();
|
||||
@@ -156,10 +157,12 @@ export class HomePage {
|
||||
async logOut() {
|
||||
await this.logoutButton.click();
|
||||
|
||||
await this.loginButton.waitFor({
|
||||
await this.page.getByRole("textbox", { name: "Username" }).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
|
||||
await expect(this.loginButton).toBeVisible();
|
||||
await expect(
|
||||
this.page.getByRole("textbox", { name: "Username" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,23 +14,23 @@
|
||||
"dependencies": {
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwindcss": "^4.1.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
"dependencies": {
|
||||
"hash-slash": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwindcss": "^4.1.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,21 @@
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-view": "^1.39.1",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,21 +22,21 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.509.0",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,20 +24,20 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.485.0",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jazz-run": "workspace:*",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.536.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
@@ -29,12 +29,12 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react-swc": "^3.10.1",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"globals": "^15.11.0",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "catalog:default",
|
||||
"vite": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ In this case, provide the WebSocket endpoint your proxy exposes as the sync serv
|
||||
|
||||
### Command line options:
|
||||
|
||||
- `--host` / `-h` - the host to run the sync server on. Defaults to 127.0.0.1.
|
||||
- `--port` / `-p` - the port to run the sync server on. Defaults to 4200.
|
||||
- `--in-memory` - keep CoValues in-memory only and do sync only, no persistence. Persistence is enabled by default.
|
||||
- `--db` - the path to the file where to store the data (SQLite). Defaults to `sync-db/storage.db`.
|
||||
|
||||
22
package.json
22
package.json
@@ -12,14 +12,14 @@
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:default",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@changesets/cli": "^2.27.10",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"@vitest/browser": "catalog:default",
|
||||
"@vitest/coverage-istanbul": "catalog:default",
|
||||
"@vitest/coverage-v8": "catalog:default",
|
||||
"@vitest/ui": "catalog:default",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/coverage-istanbul": "3.2.4",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"happy-dom": "^17.4.4",
|
||||
"jazz-run": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
@@ -28,7 +28,7 @@
|
||||
"playwright": "^1.50.1",
|
||||
"turbo": "^2.3.1",
|
||||
"typedoc": "^0.25.13",
|
||||
"vitest": "catalog:default"
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
@@ -59,11 +59,11 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"vite": "catalog:default",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"vite": "6.3.5",
|
||||
"esbuild": "0.24.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- cojson@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -9,7 +9,7 @@
|
||||
"cojson": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:default",
|
||||
"typescript": "5.6.2",
|
||||
"webdriverio": "^8.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- cojson@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"typescript": "catalog:default"
|
||||
"typescript": "5.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --sourceMap --outDir dist",
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- cojson@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
@@ -20,7 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"typescript": "catalog:default",
|
||||
"typescript": "5.6.2",
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# cojson
|
||||
|
||||
## 0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 71c1411: Removed some unnecessary content messages sent after a local transaction when sending a value as dependency before the ack response
|
||||
- 2d11d44: Make the CoValueCore.unmount function detach the CoValue from LocalNode
|
||||
|
||||
## 0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f0c73d9: Allow storing empty content with header in handleNewContent
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"libsql": "^0.5.13",
|
||||
"typescript": "catalog:default"
|
||||
"typescript": "5.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
|
||||
@@ -33,11 +33,7 @@ export class GarbageCollector {
|
||||
const timeSinceLastAccessed = currentTime - verified.lastAccessed;
|
||||
|
||||
if (timeSinceLastAccessed > GARBAGE_COLLECTOR_CONFIG.MAX_AGE) {
|
||||
const unmounted = coValue.unmount();
|
||||
|
||||
if (unmounted) {
|
||||
this.coValues.delete(coValue.id);
|
||||
}
|
||||
coValue.unmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
CoValueHeader,
|
||||
Transaction,
|
||||
VerifiedState,
|
||||
} from "./coValueCore/verifiedState.js";
|
||||
import { CoValueHeader, Transaction } from "./coValueCore/verifiedState.js";
|
||||
import { TRANSACTION_CONFIG } from "./config.js";
|
||||
import { Signature } from "./crypto/crypto.js";
|
||||
import { RawCoID, SessionID } from "./ids.js";
|
||||
@@ -65,6 +61,7 @@ export function exceedsRecommendedSize(
|
||||
|
||||
export function knownStateFromContent(content: NewContentMessage) {
|
||||
const knownState = emptyKnownState(content.id);
|
||||
knownState.header = Boolean(content.header);
|
||||
|
||||
for (const [sessionID, session] of Object.entries(content.new)) {
|
||||
knownState.sessions[sessionID as SessionID] =
|
||||
|
||||
@@ -219,6 +219,8 @@ export class CoValueCore {
|
||||
this.groupInvalidationSubscription = undefined;
|
||||
}
|
||||
|
||||
this.node.internalDeleteCoValue(this.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -706,7 +706,10 @@ export class SyncManager {
|
||||
|
||||
const syncedPeers = [];
|
||||
|
||||
if (from !== "storage" && Object.keys(contentToStore.new).length > 0) {
|
||||
const hasNewContent =
|
||||
contentToStore.header || Object.keys(contentToStore.new).length > 0;
|
||||
|
||||
if (from !== "storage" && hasNewContent) {
|
||||
this.storeContent(contentToStore);
|
||||
}
|
||||
|
||||
|
||||
215
packages/cojson/src/tests/coValueContentMessage.test.ts
Normal file
215
packages/cojson/src/tests/coValueContentMessage.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { knownStateFromContent } from "../coValueContentMessage.js";
|
||||
import { emptyKnownState } from "../sync.js";
|
||||
import type { NewContentMessage } from "../sync.js";
|
||||
import type { RawCoID, SessionID } from "../ids.js";
|
||||
import { stableStringify } from "../jsonStringify.js";
|
||||
import { CO_VALUE_PRIORITY } from "../priority.js";
|
||||
|
||||
describe("knownStateFromContent", () => {
|
||||
const mockCoID: RawCoID = "co_z1234567890abcdef";
|
||||
const mockSessionID1: SessionID = "sealer_z123/signer_z456_session_z789";
|
||||
const mockSessionID2: SessionID = "sealer_zabc/signer_zdef_session_zghi";
|
||||
|
||||
test("returns empty known state for content with no header and no sessions", () => {
|
||||
const content: NewContentMessage = {
|
||||
action: "content",
|
||||
id: mockCoID,
|
||||
header: undefined,
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
new: {},
|
||||
};
|
||||
|
||||
const result = knownStateFromContent(content);
|
||||
const expected = emptyKnownState(mockCoID);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(result.id).toBe(mockCoID);
|
||||
expect(result.header).toBe(false);
|
||||
expect(result.sessions).toEqual({});
|
||||
});
|
||||
|
||||
test("sets header to true when content has header", () => {
|
||||
const content: NewContentMessage = {
|
||||
action: "content",
|
||||
id: mockCoID,
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
uniqueness: null,
|
||||
createdAt: null,
|
||||
},
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
new: {},
|
||||
};
|
||||
|
||||
const result = knownStateFromContent(content);
|
||||
|
||||
expect(result.header).toBe(true);
|
||||
expect(result.id).toBe(mockCoID);
|
||||
expect(result.sessions).toEqual({});
|
||||
});
|
||||
|
||||
test("sets header to false when content has no header", () => {
|
||||
const content: NewContentMessage = {
|
||||
action: "content",
|
||||
id: mockCoID,
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
new: {},
|
||||
};
|
||||
|
||||
const result = knownStateFromContent(content);
|
||||
|
||||
expect(result.header).toBe(false);
|
||||
expect(result.id).toBe(mockCoID);
|
||||
expect(result.sessions).toEqual({});
|
||||
});
|
||||
|
||||
test("calculates session states correctly for single session", () => {
|
||||
const content: NewContentMessage = {
|
||||
action: "content",
|
||||
id: mockCoID,
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
uniqueness: null,
|
||||
createdAt: null,
|
||||
},
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
new: {
|
||||
[mockSessionID1]: {
|
||||
after: 5,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
],
|
||||
lastSignature: "signature_z1234",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = knownStateFromContent(content);
|
||||
|
||||
expect(result.header).toBe(true);
|
||||
expect(result.sessions[mockSessionID1]).toBe(8); // 5 + 3
|
||||
expect(Object.keys(result.sessions)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("calculates session states correctly for multiple sessions", () => {
|
||||
const content: NewContentMessage = {
|
||||
action: "content",
|
||||
id: mockCoID,
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
new: {
|
||||
[mockSessionID1]: {
|
||||
after: 3,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
],
|
||||
lastSignature: "signature_z1234",
|
||||
},
|
||||
[mockSessionID2]: {
|
||||
after: 7,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
],
|
||||
lastSignature: "signature_z1234",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = knownStateFromContent(content);
|
||||
|
||||
expect(result.header).toBe(false);
|
||||
expect(result.sessions[mockSessionID1]).toBe(5); // 3 + 2
|
||||
expect(result.sessions[mockSessionID2]).toBe(11); // 7 + 4
|
||||
expect(Object.keys(result.sessions)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("handles session with no transactions", () => {
|
||||
const content: NewContentMessage = {
|
||||
action: "content",
|
||||
id: mockCoID,
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
new: {
|
||||
[mockSessionID1]: {
|
||||
after: 10,
|
||||
newTransactions: [],
|
||||
lastSignature: "signature_z1234",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = knownStateFromContent(content);
|
||||
|
||||
expect(result.sessions[mockSessionID1]).toBe(10); // 10 + 0
|
||||
});
|
||||
|
||||
test("handles session with after index 0", () => {
|
||||
const content: NewContentMessage = {
|
||||
action: "content",
|
||||
id: mockCoID,
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
new: {
|
||||
[mockSessionID1]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([]),
|
||||
},
|
||||
],
|
||||
lastSignature: "signature_z1234",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = knownStateFromContent(content);
|
||||
|
||||
expect(result.sessions[mockSessionID1]).toBe(1); // 0 + 1
|
||||
});
|
||||
});
|
||||
@@ -163,12 +163,10 @@ describe("sync after the garbage collector has run", () => {
|
||||
"edge -> storage | LOAD Map sessions: empty",
|
||||
"storage -> edge | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge -> server | CONTENT Map header: true new: ",
|
||||
"edge -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> edge | KNOWN Map sessions: header/1",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> edge | KNOWN Map sessions: header/1",
|
||||
"client -> edge | KNOWN Group sessions: header/5",
|
||||
"client -> edge | KNOWN Map sessions: header/1",
|
||||
]
|
||||
|
||||
@@ -973,7 +973,6 @@ describe("loading coValues from server", () => {
|
||||
"server -> client | KNOWN Group sessions: header/6",
|
||||
"server -> client | KNOWN ParentGroup sessions: header/8",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
"client -> server | CONTENT ParentGroup header: true new: ",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -132,6 +132,10 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
|
||||
group.extend(parentGroup);
|
||||
|
||||
// We wait for sync here to avoid flakiness on CI
|
||||
await parentGroup.core.waitForSync();
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world");
|
||||
|
||||
@@ -154,19 +158,17 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
"edge-france -> core | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"edge-france -> storage | CONTENT Group header: false new: After: 3 New: 2",
|
||||
"edge-france -> core | CONTENT Group header: false new: After: 3 New: 2",
|
||||
"edge-france -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge-france -> core | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"core -> edge-france | KNOWN Group sessions: header/3",
|
||||
"core -> storage | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"core -> edge-france | KNOWN ParentGroup sessions: header/6",
|
||||
"core -> storage | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"core -> edge-france | KNOWN Group sessions: header/5",
|
||||
"core -> storage | CONTENT Group header: false new: After: 3 New: 2",
|
||||
"edge-france -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge-france -> core | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"core -> edge-france | KNOWN Map sessions: header/1",
|
||||
"core -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge-france -> core | CONTENT ParentGroup header: true new: ",
|
||||
"client -> edge-italy | LOAD Map sessions: empty",
|
||||
"core -> edge-france | KNOWN ParentGroup sessions: header/6",
|
||||
"edge-italy -> storage | LOAD Map sessions: empty",
|
||||
"storage -> edge-italy | KNOWN Map sessions: empty",
|
||||
"edge-italy -> core | LOAD Map sessions: empty",
|
||||
@@ -588,11 +590,9 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
"client -> edge | KNOWN Group sessions: header/5",
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> edge | KNOWN Map sessions: header/0",
|
||||
"client -> storage | CONTENT Map header: true new: ",
|
||||
"client -> edge | KNOWN Map sessions: header/41",
|
||||
"client -> storage | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"storage -> client | KNOWN CORRECTION Map sessions: empty",
|
||||
"client -> storage | CONTENT Map header: true new: expectContentUntil: header/100",
|
||||
"client -> storage | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 41 New: 21",
|
||||
"edge -> client | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"edge -> core | CONTENT Group header: true new: After: 0 New: 5",
|
||||
@@ -607,11 +607,9 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
"core -> edge | KNOWN Group sessions: header/5",
|
||||
"core -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"core -> edge | KNOWN Map sessions: header/0",
|
||||
"core -> storage | CONTENT Map header: true new: ",
|
||||
"core -> edge | KNOWN Map sessions: header/41",
|
||||
"core -> storage | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"storage -> core | KNOWN CORRECTION Map sessions: empty",
|
||||
"core -> storage | CONTENT Map header: true new: expectContentUntil: header/100",
|
||||
"core -> storage | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"core -> edge | KNOWN Map sessions: header/62",
|
||||
"core -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"core -> edge | KNOWN Map sessions: header/83",
|
||||
|
||||
@@ -47,8 +47,6 @@ describe("peer reconciliation", () => {
|
||||
"server -> client | KNOWN Map sessions: empty",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
"client -> server | CONTENT Group header: true new: ",
|
||||
"client -> server | CONTENT Map header: true new: ",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -555,4 +555,53 @@ describe("client syncs with a server with storage", () => {
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("should store values with no transactions", async () => {
|
||||
const alice = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
const group = alice.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
const map = group.createMap();
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const bob = setupTestNode();
|
||||
bob.connectToSyncServer({
|
||||
ourName: "bob",
|
||||
});
|
||||
const { storage } = bob.addStorage({
|
||||
ourName: "bob",
|
||||
});
|
||||
|
||||
SyncMessagesLog.clear(); // We want to focus on the sync messages happening from now
|
||||
|
||||
await loadCoValueOrFail(bob.node, map.id);
|
||||
|
||||
// The map should be stored in bob's storage
|
||||
expect(storage.getKnownState(map.id)).toEqual({
|
||||
header: true,
|
||||
id: map.id,
|
||||
sessions: {},
|
||||
});
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
Group: group.core,
|
||||
Map: map.core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"bob -> storage | LOAD Map sessions: empty",
|
||||
"storage -> bob | KNOWN Map sessions: empty",
|
||||
"bob -> server | LOAD Map sessions: empty",
|
||||
"server -> bob | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> bob | CONTENT Map header: true new: ",
|
||||
"bob -> server | KNOWN Group sessions: header/5",
|
||||
"bob -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"bob -> server | KNOWN Map sessions: header/0",
|
||||
"bob -> storage | CONTENT Map header: true new: ",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,6 @@ describe("client to server upload", () => {
|
||||
"server -> client | KNOWN ParentGroup sessions: header/6",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
"client -> server | CONTENT ParentGroup header: true new: ",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# jazz-react
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ac3e694]
|
||||
- Updated dependencies [6dbb053]
|
||||
- Updated dependencies [1a182f0]
|
||||
- jazz-tools@0.17.8
|
||||
- cojson@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
- jazz-tools@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [82de51c]
|
||||
- Updated dependencies [694b168]
|
||||
- jazz-tools@0.17.6
|
||||
- cojson@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- Updated dependencies [5963658]
|
||||
- cojson@0.17.5
|
||||
- jazz-tools@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7dd3d00]
|
||||
- jazz-tools@0.17.4
|
||||
- cojson@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
- jazz-tools@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "community-jazz-vue",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"@types/degit": "^2.8.3",
|
||||
"@types/gradient-string": "^1.1.2",
|
||||
"@types/inquirer": "^9.0.3",
|
||||
"typescript": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
"typescript": "5.6.2",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"react": "^18.3.1",
|
||||
"typescript": "catalog:default"
|
||||
"typescript": "5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# jazz-auth-betterauth
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ac3e694]
|
||||
- Updated dependencies [6dbb053]
|
||||
- Updated dependencies [1a182f0]
|
||||
- jazz-tools@0.17.8
|
||||
- jazz-betterauth-client-plugin@0.17.8
|
||||
- cojson@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
- jazz-betterauth-client-plugin@0.17.7
|
||||
- jazz-tools@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [82de51c]
|
||||
- Updated dependencies [694b168]
|
||||
- jazz-tools@0.17.6
|
||||
- jazz-betterauth-client-plugin@0.17.6
|
||||
- cojson@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- Updated dependencies [5963658]
|
||||
- cojson@0.17.5
|
||||
- jazz-tools@0.17.5
|
||||
- jazz-betterauth-client-plugin@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7dd3d00]
|
||||
- jazz-tools@0.17.4
|
||||
- jazz-betterauth-client-plugin@0.17.4
|
||||
- cojson@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
- jazz-tools@0.17.3
|
||||
- jazz-betterauth-client-plugin@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-auth-betterauth",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
@@ -19,6 +19,6 @@
|
||||
"test:watch": "vitest --watch --root ../../ --project jazz-auth-betterauth"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:default"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# jazz-betterauth-client-plugin
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-betterauth-client-plugin",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# jazz-betterauth-server-plugin
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ac3e694]
|
||||
- Updated dependencies [6dbb053]
|
||||
- Updated dependencies [1a182f0]
|
||||
- jazz-tools@0.17.8
|
||||
- cojson@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
- jazz-tools@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [82de51c]
|
||||
- Updated dependencies [694b168]
|
||||
- jazz-tools@0.17.6
|
||||
- cojson@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- Updated dependencies [5963658]
|
||||
- cojson@0.17.5
|
||||
- jazz-tools@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7dd3d00]
|
||||
- jazz-tools@0.17.4
|
||||
- cojson@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
- jazz-tools@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-betterauth-server-plugin",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# jazz-react-auth-betterauth
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ac3e694]
|
||||
- Updated dependencies [6dbb053]
|
||||
- Updated dependencies [1a182f0]
|
||||
- jazz-tools@0.17.8
|
||||
- jazz-auth-betterauth@0.17.8
|
||||
- jazz-betterauth-client-plugin@0.17.8
|
||||
- cojson@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
- jazz-auth-betterauth@0.17.7
|
||||
- jazz-betterauth-client-plugin@0.17.7
|
||||
- jazz-tools@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [82de51c]
|
||||
- Updated dependencies [694b168]
|
||||
- jazz-tools@0.17.6
|
||||
- jazz-auth-betterauth@0.17.6
|
||||
- jazz-betterauth-client-plugin@0.17.6
|
||||
- cojson@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- Updated dependencies [5963658]
|
||||
- cojson@0.17.5
|
||||
- jazz-tools@0.17.5
|
||||
- jazz-auth-betterauth@0.17.5
|
||||
- jazz-betterauth-client-plugin@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7dd3d00]
|
||||
- jazz-tools@0.17.4
|
||||
- jazz-auth-betterauth@0.17.4
|
||||
- jazz-betterauth-client-plugin@0.17.4
|
||||
- cojson@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
- jazz-auth-betterauth@0.17.3
|
||||
- jazz-tools@0.17.3
|
||||
- jazz-betterauth-client-plugin@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-react-auth-betterauth",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.tsx",
|
||||
@@ -23,6 +23,6 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^17 || ^18 || ^19",
|
||||
"typescript": "catalog:default"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,70 @@
|
||||
# jazz-run
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ac3e694]
|
||||
- Updated dependencies [6dbb053]
|
||||
- Updated dependencies [1a182f0]
|
||||
- jazz-tools@0.17.8
|
||||
- cojson@0.17.8
|
||||
- cojson-storage-sqlite@0.17.8
|
||||
- cojson-transport-ws@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feaa69e: Add host option to the jazz-run sync command
|
||||
- cojson@0.17.7
|
||||
- cojson-storage-sqlite@0.17.7
|
||||
- cojson-transport-ws@0.17.7
|
||||
- jazz-tools@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [82de51c]
|
||||
- Updated dependencies [694b168]
|
||||
- jazz-tools@0.17.6
|
||||
- cojson@0.17.6
|
||||
- cojson-storage-sqlite@0.17.6
|
||||
- cojson-transport-ws@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- Updated dependencies [5963658]
|
||||
- cojson@0.17.5
|
||||
- jazz-tools@0.17.5
|
||||
- cojson-storage-sqlite@0.17.5
|
||||
- cojson-transport-ws@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7dd3d00]
|
||||
- jazz-tools@0.17.4
|
||||
- cojson@0.17.4
|
||||
- cojson-storage-sqlite@0.17.4
|
||||
- cojson-transport-ws@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
- cojson-storage-sqlite@0.17.3
|
||||
- cojson-transport-ws@0.17.3
|
||||
- jazz-tools@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bin": "./dist/index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"exports": {
|
||||
"./startSyncServer": {
|
||||
"types": "./dist/startSyncServer.d.ts",
|
||||
@@ -28,15 +28,15 @@
|
||||
"@effect/printer-ansi": "^0.34.5",
|
||||
"@effect/schema": "^0.71.1",
|
||||
"@effect/typeclass": "^0.25.5",
|
||||
"cojson": "workspace:0.17.2",
|
||||
"cojson-storage-sqlite": "workspace:0.17.2",
|
||||
"cojson-transport-ws": "workspace:0.17.2",
|
||||
"cojson": "workspace:0.17.8",
|
||||
"cojson-storage-sqlite": "workspace:0.17.8",
|
||||
"cojson-transport-ws": "workspace:0.17.8",
|
||||
"effect": "^3.6.5",
|
||||
"jazz-tools": "workspace:0.17.2",
|
||||
"jazz-tools": "workspace:0.17.8",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "8.5.10",
|
||||
"typescript": "catalog:default"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/jazz-run/src/config.ts
Normal file
6
packages/jazz-run/src/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const serverDefaults = {
|
||||
host: "127.0.0.1",
|
||||
port: 4200,
|
||||
inMemory: false,
|
||||
db: "sync-db/storage.db",
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { NodeContext, NodeRuntime } from "@effect/platform-node";
|
||||
import { Console, Effect } from "effect";
|
||||
import { createWorkerAccount } from "./createWorkerAccount.js";
|
||||
import { startSyncServer } from "./startSyncServer.js";
|
||||
import { serverDefaults } from "./config.js";
|
||||
|
||||
const jazzTools = Command.make("jazz-tools");
|
||||
|
||||
@@ -39,36 +40,63 @@ const accountCommand = Command.make("account").pipe(
|
||||
Command.withSubcommands([createAccountCommand]),
|
||||
);
|
||||
|
||||
const hostOption = Options.text("host")
|
||||
.pipe(Options.withAlias("h"))
|
||||
.pipe(
|
||||
Options.withDescription(
|
||||
`The host to listen on. Default is ${serverDefaults.host}`,
|
||||
),
|
||||
)
|
||||
.pipe(Options.withDefault(serverDefaults.host));
|
||||
|
||||
const portOption = Options.text("port")
|
||||
.pipe(Options.withAlias("p"))
|
||||
.pipe(
|
||||
Options.withDescription(
|
||||
"Select a different port for the WebSocket server. Default is 4200",
|
||||
`Select a different port for the WebSocket server. Default is ${serverDefaults.port}`,
|
||||
),
|
||||
)
|
||||
.pipe(Options.withDefault("4200"));
|
||||
.pipe(Options.withDefault(serverDefaults.port.toString()));
|
||||
|
||||
const inMemoryOption = Options.boolean("in-memory").pipe(
|
||||
Options.withDescription("Use an in-memory storage instead of file-based"),
|
||||
Options.withDescription("Use an in-memory storage instead of file-based."),
|
||||
);
|
||||
|
||||
const dbOption = Options.file("db")
|
||||
.pipe(
|
||||
Options.withDescription(
|
||||
"The path to the file where to store the data. Default is 'sync-db/storage.db'",
|
||||
`The path to the file where to store the data. Default is '${serverDefaults.db}'`,
|
||||
),
|
||||
)
|
||||
.pipe(Options.withDefault("sync-db/storage.db"));
|
||||
.pipe(Options.withDefault(serverDefaults.db));
|
||||
|
||||
const startSyncServerCommand = Command.make(
|
||||
"sync",
|
||||
{ port: portOption, inMemory: inMemoryOption, db: dbOption },
|
||||
({ port, inMemory, db }) => {
|
||||
{
|
||||
host: hostOption,
|
||||
port: portOption,
|
||||
inMemory: inMemoryOption,
|
||||
db: dbOption,
|
||||
},
|
||||
({ host, port, inMemory, db }) => {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => startSyncServer({ port, inMemory, db }));
|
||||
const server = yield* Effect.promise(() =>
|
||||
startSyncServer({ host, port, inMemory, db }),
|
||||
);
|
||||
|
||||
const serverAddress = server.address();
|
||||
|
||||
if (!serverAddress) {
|
||||
return yield* Effect.fail(new Error("Failed to start sync server."));
|
||||
}
|
||||
|
||||
const socketAddress =
|
||||
typeof serverAddress === "object"
|
||||
? `${serverAddress.address}:${serverAddress.port}`
|
||||
: serverAddress;
|
||||
|
||||
yield* Console.log(
|
||||
`COJSON sync server listening on ws://127.0.0.1:${port}`,
|
||||
`COJSON sync server listening on ws://${socketAddress}`,
|
||||
);
|
||||
|
||||
// Keep the server up
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createServer } from "http";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { LocalNode } from "cojson";
|
||||
@@ -6,16 +6,19 @@ import { getBetterSqliteStorage } from "cojson-storage-sqlite";
|
||||
import { createWebSocketPeer } from "cojson-transport-ws";
|
||||
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { type SyncServer } from "./types.js";
|
||||
|
||||
export const startSyncServer = async ({
|
||||
host,
|
||||
port,
|
||||
inMemory,
|
||||
db,
|
||||
}: {
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
inMemory: boolean;
|
||||
db: string;
|
||||
}) => {
|
||||
}): Promise<SyncServer> => {
|
||||
const crypto = await WasmCrypto.create();
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
@@ -94,8 +97,6 @@ export const startSyncServer = async ({
|
||||
localNode.gracefulShutdown();
|
||||
});
|
||||
|
||||
server.listen(port ? parseInt(port) : undefined);
|
||||
|
||||
const _close = server.close;
|
||||
|
||||
server.close = () => {
|
||||
@@ -104,5 +105,13 @@ export const startSyncServer = async ({
|
||||
return _close.call(server);
|
||||
};
|
||||
|
||||
return server;
|
||||
Object.defineProperty(server, "localNode", { value: localNode });
|
||||
|
||||
server.listen(port ? parseInt(port) : undefined, host);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.once("listening", () => {
|
||||
resolve(server as SyncServer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,11 +5,13 @@ import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { createWorkerAccount } from "../createWorkerAccount.js";
|
||||
import { startSyncServer } from "../startSyncServer.js";
|
||||
import { serverDefaults } from "../config.js";
|
||||
|
||||
describe("createWorkerAccount - integration tests", () => {
|
||||
it("should create a worker account using the local sync server", async () => {
|
||||
// Pass port: undefined to let the server choose a random port
|
||||
const server = await startSyncServer({
|
||||
host: serverDefaults.host,
|
||||
port: undefined,
|
||||
inMemory: true,
|
||||
db: "",
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { LocalNode } from "cojson";
|
||||
import { co, z } from "jazz-tools";
|
||||
import { startWorker } from "jazz-tools/worker";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, afterAll } from "vitest";
|
||||
import { createWorkerAccount } from "../createWorkerAccount.js";
|
||||
import { startSyncServer } from "../startSyncServer.js";
|
||||
import { serverDefaults } from "../config.js";
|
||||
import { unlinkSync } from "node:fs";
|
||||
|
||||
const TestMap = co.map({
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
describe("startSyncServer", () => {
|
||||
// Fails on CI, but works locally
|
||||
test.skip("persists values in storage and loads them after restart", async () => {
|
||||
// Create a temporary database file
|
||||
const dbPath = join(tmpdir(), `test-${randomUUID()}.db`);
|
||||
const dbPath = join(tmpdir(), `test-${randomUUID()}.db`);
|
||||
|
||||
afterAll(() => {
|
||||
unlinkSync(dbPath);
|
||||
});
|
||||
|
||||
describe("startSyncServer", () => {
|
||||
test("persists values in storage and loads them after restart", async () => {
|
||||
// Start first server instance
|
||||
const server1 = await startSyncServer({
|
||||
host: serverDefaults.host,
|
||||
port: "0", // Random available port
|
||||
inMemory: false,
|
||||
db: dbPath,
|
||||
@@ -49,6 +53,7 @@ describe("startSyncServer", () => {
|
||||
|
||||
// Start second server instance with same DB
|
||||
const server2 = await startSyncServer({
|
||||
host: serverDefaults.host,
|
||||
port: "0",
|
||||
inMemory: false,
|
||||
db: dbPath,
|
||||
@@ -75,4 +80,21 @@ describe("startSyncServer", () => {
|
||||
await worker2.done();
|
||||
server2.close();
|
||||
});
|
||||
|
||||
test("starts a sync server with a specific host and port", async () => {
|
||||
const server = await startSyncServer({
|
||||
host: "0.0.0.0",
|
||||
port: "4900",
|
||||
inMemory: false,
|
||||
db: dbPath,
|
||||
});
|
||||
|
||||
expect(server.address()).toEqual({
|
||||
address: "0.0.0.0",
|
||||
port: 4900,
|
||||
family: "IPv4",
|
||||
});
|
||||
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { afterAll, describe, expect, onTestFinished, test } from "vitest";
|
||||
import { createWorkerAccount } from "../createWorkerAccount.js";
|
||||
import { startSyncServer } from "../startSyncServer.js";
|
||||
import { waitFor } from "./utils.js";
|
||||
import { serverDefaults } from "../config.js";
|
||||
|
||||
const dbPath = join(tmpdir(), `test-${randomUUID()}.db`);
|
||||
|
||||
@@ -30,9 +31,9 @@ async function setup<
|
||||
| (AccountClass<Account> & CoValueFromRaw<Account>)
|
||||
| AnyAccountSchema,
|
||||
>(AccountSchema?: S) {
|
||||
const { server, port } = await setupSyncServer();
|
||||
const { server, port, host } = await setupSyncServer();
|
||||
|
||||
const syncServer = `ws://localhost:${port}`;
|
||||
const syncServer = `ws://${host}:${port}`;
|
||||
|
||||
const { worker, done, waitForConnection, subscribeToConnectionChange } =
|
||||
await setupWorker(syncServer, AccountSchema);
|
||||
@@ -43,13 +44,18 @@ async function setup<
|
||||
syncServer,
|
||||
server,
|
||||
port,
|
||||
host,
|
||||
waitForConnection,
|
||||
subscribeToConnectionChange,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupSyncServer(defaultPort = "0") {
|
||||
async function setupSyncServer(
|
||||
defaultHost = serverDefaults.host,
|
||||
defaultPort = "0",
|
||||
) {
|
||||
const server = await startSyncServer({
|
||||
host: defaultHost,
|
||||
port: defaultPort,
|
||||
inMemory: false,
|
||||
db: dbPath,
|
||||
@@ -61,7 +67,7 @@ async function setupSyncServer(defaultPort = "0") {
|
||||
server.close();
|
||||
});
|
||||
|
||||
return { server, port };
|
||||
return { server, port, host: defaultHost };
|
||||
}
|
||||
|
||||
async function setupWorker<
|
||||
@@ -258,6 +264,7 @@ describe("startWorker integration", () => {
|
||||
|
||||
// Start a new sync server on the same port
|
||||
const newServer = await startSyncServer({
|
||||
host: worker1.host,
|
||||
port: worker1.port,
|
||||
inMemory: true,
|
||||
db: "",
|
||||
@@ -290,6 +297,7 @@ describe("startWorker integration", () => {
|
||||
|
||||
// Start a new sync server on the same port
|
||||
const newServer = await startSyncServer({
|
||||
host: worker1.host,
|
||||
port: worker1.port,
|
||||
inMemory: true,
|
||||
db: "",
|
||||
@@ -326,6 +334,7 @@ describe("startWorker integration", () => {
|
||||
|
||||
// Start a new sync server on the same port
|
||||
const newServer = await startSyncServer({
|
||||
host: worker1.host,
|
||||
port: worker1.port,
|
||||
inMemory: true,
|
||||
db: "",
|
||||
|
||||
4
packages/jazz-run/src/types.ts
Normal file
4
packages/jazz-run/src/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { type Server } from "node:http";
|
||||
import { type LocalNode } from "cojson";
|
||||
|
||||
export type SyncServer = Server & { localNode: LocalNode };
|
||||
@@ -1,5 +1,63 @@
|
||||
# jazz-tools
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ac3e694: Fixed an issue where CoValue schemas could be incorrectly passed to `co.map` and `co.profile` schema definers.
|
||||
- 6dbb053: Prosemirror: fix RangeError triggered when creating invalid HTML
|
||||
- 1a182f0: Add missing BaseProfileShape export
|
||||
- cojson@0.17.8
|
||||
- cojson-storage-indexeddb@0.17.8
|
||||
- cojson-transport-ws@0.17.8
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.17.7
|
||||
- cojson-storage-indexeddb@0.17.7
|
||||
- cojson-transport-ws@0.17.7
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 82de51c: allow creating CoValues from JSON without an active account
|
||||
- 694b168: get resized image's id without triggering shallow load in `loadImageBySize`
|
||||
- cojson@0.17.6
|
||||
- cojson-storage-indexeddb@0.17.6
|
||||
- cojson-transport-ws@0.17.6
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5963658: Implement/expose loadUnique and upsertUnique on co.list and co.record
|
||||
- Updated dependencies [71c1411]
|
||||
- Updated dependencies [2d11d44]
|
||||
- cojson@0.17.5
|
||||
- cojson-storage-indexeddb@0.17.5
|
||||
- cojson-transport-ws@0.17.5
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7dd3d00: Export `Ref` class from jazz-tools package
|
||||
- cojson@0.17.4
|
||||
- cojson-storage-indexeddb@0.17.4
|
||||
- cojson-transport-ws@0.17.4
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f0c73d9]
|
||||
- cojson@0.17.3
|
||||
- cojson-storage-indexeddb@0.17.3
|
||||
- cojson-transport-ws@0.17.3
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.8",
|
||||
"dependencies": {
|
||||
"@manuscripts/prosemirror-recreate-steps": "^0.1.4",
|
||||
"@scure/base": "1.2.1",
|
||||
@@ -186,8 +186,8 @@
|
||||
"playwright": "^1.50.1",
|
||||
"queueueue": "^4.1.2",
|
||||
"tsup": "8.5.0",
|
||||
"typescript": "catalog:default",
|
||||
"vitest": "catalog:default",
|
||||
"typescript": "5.6.2",
|
||||
"vitest": "3.2.4",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -347,7 +347,6 @@ describe("loadImageBySize", async () => {
|
||||
|
||||
it("returns the image already loaded", async () => {
|
||||
const account = await setupJazzTestSync({ asyncPeers: true });
|
||||
const account2 = await createJazzTestAccount();
|
||||
|
||||
setActiveAccount(account);
|
||||
|
||||
@@ -363,9 +362,11 @@ describe("loadImageBySize", async () => {
|
||||
group,
|
||||
);
|
||||
|
||||
const account2 = await createJazzTestAccount();
|
||||
setActiveAccount(account2);
|
||||
|
||||
const result = await loadImageBySize(imageDef, 1024, 1024);
|
||||
const result = await loadImageBySize(imageDef.id, 1024, 1024);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.image.id).toBe(imageDef["1024x1024"]!.id);
|
||||
expect(result?.image.isBinaryStreamEnded()).toBe(true);
|
||||
expect(result?.image.asBase64()).toStrictEqual(expect.any(String));
|
||||
|
||||
@@ -185,7 +185,11 @@ export async function loadImageBySize(
|
||||
const bestTarget =
|
||||
sortedSizes.find((el) => el.match > 0.95) || sortedSizes.at(-1)!;
|
||||
|
||||
const file = image[bestTarget.size[2]];
|
||||
// The image's `wxh` keys reference FileStream.
|
||||
// image[bestTarget.size[2]] returns undefined if FileStream hasn't loaded yet.
|
||||
// Since we only need the file's ID to fetch it later, we check the raw _refs
|
||||
// which contain only the linked covalue's ID.
|
||||
const file = image._refs[bestTarget.size[2]];
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { recreateTransform } from "@manuscripts/prosemirror-recreate-steps";
|
||||
import { CoRichText } from "jazz-tools";
|
||||
import { Transaction } from "prosemirror-state";
|
||||
import { EditorState, Transaction } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { htmlToProseMirror, proseMirrorToHtml } from "./converter.js";
|
||||
|
||||
@@ -34,6 +34,8 @@ export const META_KEY = "fromJazz";
|
||||
export function createSyncHandlers(coRichText: CoRichText | undefined) {
|
||||
// Store the editor view in a closure
|
||||
let view: EditorView | undefined;
|
||||
let localChange = false;
|
||||
let remoteChange = false;
|
||||
|
||||
/**
|
||||
* Handles changes from CoRichText by updating the ProseMirror editor.
|
||||
@@ -47,24 +49,47 @@ export function createSyncHandlers(coRichText: CoRichText | undefined) {
|
||||
* @param newText - The updated CoRichText instance
|
||||
*/
|
||||
function handleCoRichTextChange(newText: CoRichText) {
|
||||
if (!view || !newText) return;
|
||||
if (!view || !newText || localChange || remoteChange) return;
|
||||
|
||||
const pmDoc = htmlToProseMirror(
|
||||
newText.toString(),
|
||||
view.state.doc.type.schema,
|
||||
);
|
||||
const transform = recreateTransform(view.state.doc, pmDoc);
|
||||
const currentView = view;
|
||||
remoteChange = true;
|
||||
|
||||
// Create a new transaction
|
||||
const tr = view.state.tr;
|
||||
// Changes on CoPlainText are emitted word by word, which means that it creates
|
||||
// invalid intermediate states when wrapping a document with HTML tags
|
||||
// To fix the issue, we throttle the changes to the next microtask
|
||||
queueMicrotask(() => {
|
||||
const pmDoc = htmlToProseMirror(
|
||||
newText.toString(),
|
||||
currentView.state.doc.type.schema,
|
||||
);
|
||||
|
||||
// Apply all steps from the transform to the transaction
|
||||
transform.steps.forEach((step) => {
|
||||
tr.step(step);
|
||||
try {
|
||||
const transform = recreateTransform(currentView.state.doc, pmDoc);
|
||||
|
||||
// Create a new transaction
|
||||
const tr = currentView.state.tr;
|
||||
|
||||
// Apply all steps from the transform to the transaction
|
||||
transform.steps.forEach((step) => {
|
||||
tr.step(step);
|
||||
});
|
||||
|
||||
tr.setMeta(META_KEY, true);
|
||||
|
||||
currentView.dispatch(tr);
|
||||
} catch (err) {
|
||||
// Sometimes recreateTransform fails, so we just rebuild the doc from scratch
|
||||
const newState = EditorState.create({
|
||||
schema: currentView.state.schema,
|
||||
doc: pmDoc,
|
||||
plugins: currentView.state.plugins,
|
||||
selection: currentView.state.selection,
|
||||
});
|
||||
currentView.updateState(newState);
|
||||
} finally {
|
||||
remoteChange = false;
|
||||
}
|
||||
});
|
||||
|
||||
tr.setMeta(META_KEY, true);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +107,12 @@ export function createSyncHandlers(coRichText: CoRichText | undefined) {
|
||||
|
||||
if (tr.docChanged) {
|
||||
const str = proseMirrorToHtml(tr.doc);
|
||||
coRichText.applyDiff(str);
|
||||
localChange = true;
|
||||
try {
|
||||
coRichText.applyDiff(str);
|
||||
} finally {
|
||||
localChange = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { Account, CoRichText } from "jazz-tools";
|
||||
import { CoRichText } from "jazz-tools";
|
||||
import { createJazzTestAccount, setupJazzTestSync } from "jazz-tools/testing";
|
||||
import { schema } from "prosemirror-schema-basic";
|
||||
import { EditorState, TextSelection } from "prosemirror-state";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
onTestFinished,
|
||||
} from "vitest";
|
||||
import { createJazzPlugin } from "../lib/plugin";
|
||||
import { Schema } from "prosemirror-model";
|
||||
import { schema as basicSchema } from "prosemirror-schema-basic";
|
||||
import { addListNodes } from "prosemirror-schema-list";
|
||||
|
||||
let account: Account;
|
||||
let coRichText: CoRichText;
|
||||
let plugin: Plugin;
|
||||
let state: EditorState;
|
||||
let view: EditorView;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupJazzTestSync();
|
||||
account = await createJazzTestAccount({ isCurrentActiveAccount: true });
|
||||
const schema = new Schema({
|
||||
nodes: addListNodes(basicSchema.spec.nodes, "paragraph block*", "block"),
|
||||
marks: basicSchema.spec.marks,
|
||||
});
|
||||
|
||||
async function setupTest(initialContent = "<p>Hello</p>") {
|
||||
// Create a real CoRichText with the test account as owner
|
||||
coRichText = CoRichText.create("<p>Hello</p>", account);
|
||||
const coRichText = CoRichText.create(initialContent);
|
||||
|
||||
plugin = createJazzPlugin(coRichText);
|
||||
state = EditorState.create({
|
||||
const plugin = createJazzPlugin(coRichText);
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
plugins: [plugin],
|
||||
});
|
||||
@@ -33,25 +37,32 @@ beforeEach(async () => {
|
||||
document.body.appendChild(editorElement);
|
||||
|
||||
// Initialize the editor view
|
||||
view = new EditorView(editorElement, {
|
||||
const view = new EditorView(editorElement, {
|
||||
state,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up the editor view
|
||||
if (view) {
|
||||
onTestFinished(() => {
|
||||
view.destroy();
|
||||
view.dom.remove();
|
||||
}
|
||||
editorElement.remove();
|
||||
});
|
||||
|
||||
return { coRichText, plugin, state, view, editorElement };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupJazzTestSync();
|
||||
await createJazzTestAccount({ isCurrentActiveAccount: true });
|
||||
});
|
||||
|
||||
describe("createJazzPlugin", () => {
|
||||
it("initializes editor with CoRichText content", () => {
|
||||
it("initializes editor with CoRichText content", async () => {
|
||||
const { state } = await setupTest();
|
||||
expect(state.doc.textContent).toContain("Hello");
|
||||
});
|
||||
|
||||
it("updates editor when CoRichText changes", async () => {
|
||||
const { coRichText, view } = await setupTest();
|
||||
|
||||
// Update CoRichText content
|
||||
coRichText.applyDiff("<p>Updated content</p>");
|
||||
|
||||
@@ -61,7 +72,9 @@ describe("createJazzPlugin", () => {
|
||||
expect(view.state.doc.textContent).toContain("Updated content");
|
||||
});
|
||||
|
||||
it("updates CoRichText when editor content changes", () => {
|
||||
it("updates CoRichText when editor content changes", async () => {
|
||||
const { coRichText, view } = await setupTest();
|
||||
|
||||
// Create a transaction to update the editor content
|
||||
const tr = view.state.tr.insertText(" World", 6);
|
||||
view.dispatch(tr);
|
||||
@@ -70,8 +83,8 @@ describe("createJazzPlugin", () => {
|
||||
expect(coRichText.toString()).toContain("Hello World");
|
||||
});
|
||||
|
||||
it("handles empty CoRichText initialization", () => {
|
||||
const emptyCoRichText = CoRichText.create("", account);
|
||||
it("handles empty CoRichText initialization", async () => {
|
||||
const emptyCoRichText = CoRichText.create("");
|
||||
const emptyPlugin = createJazzPlugin(emptyCoRichText);
|
||||
const emptyState = EditorState.create({
|
||||
schema,
|
||||
@@ -81,7 +94,7 @@ describe("createJazzPlugin", () => {
|
||||
expect(emptyState.doc.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("handles undefined CoRichText", () => {
|
||||
it("handles undefined CoRichText", async () => {
|
||||
const undefinedPlugin = createJazzPlugin(undefined);
|
||||
const undefinedState = EditorState.create({
|
||||
schema,
|
||||
@@ -91,7 +104,9 @@ describe("createJazzPlugin", () => {
|
||||
expect(undefinedState.doc.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("prevents infinite update loops", () => {
|
||||
it("prevents infinite update loops", async () => {
|
||||
const { coRichText, view } = await setupTest();
|
||||
|
||||
// Create a transaction that would normally trigger a CoRichText update
|
||||
const tr = view.state.tr.insertText(" Loop", 6);
|
||||
|
||||
@@ -106,7 +121,9 @@ describe("createJazzPlugin", () => {
|
||||
expect(coRichText.toString()).not.toContain("Loop");
|
||||
});
|
||||
|
||||
it.skip("preserves selection when CoRichText changes", () => {
|
||||
it("preserves selection when CoRichText changes", async () => {
|
||||
const { coRichText, view } = await setupTest();
|
||||
|
||||
// Set a selection in the editor
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, 2, 5),
|
||||
@@ -118,10 +135,49 @@ describe("createJazzPlugin", () => {
|
||||
expect(view.state.selection.to).toBe(5);
|
||||
|
||||
// Update CoRichText content
|
||||
coRichText.applyDiff("<p>Updated content</p>");
|
||||
coRichText.applyDiff("<p>Hello world</p>");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Verify selection is preserved after content update
|
||||
expect(view.state.selection.from).toBe(2);
|
||||
expect(view.state.selection.to).toBe(5);
|
||||
});
|
||||
|
||||
it("falls back to creating a new EditorState when the transform fails", async () => {
|
||||
const { coRichText, editorElement } = await setupTest(
|
||||
"<p>A <strong>hu<em>man</strong></em>.</p>",
|
||||
);
|
||||
|
||||
// Wait for the next tick to allow the update to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Update CoRichText content
|
||||
coRichText.applyDiff(
|
||||
"<ol><li><p>A <strong>hu</strong><em><strong>man</strong></em>.</p></li></ol>",
|
||||
);
|
||||
|
||||
// Wait for the next tick to allow the update to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(editorElement.querySelector(".ProseMirror")?.innerHTML).toBe(
|
||||
"<ol><li><p>A <strong>hu</strong><em><strong>man</strong></em>.</p></li></ol>",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles updates with emojis", async () => {
|
||||
const { coRichText, editorElement } = await setupTest(
|
||||
"<p>A <strong>hu</strong><em><strong>man</strong></em>.</p>",
|
||||
);
|
||||
|
||||
// Update CoRichText content
|
||||
coRichText.applyDiff("<p>A human💪</p>");
|
||||
|
||||
// Wait for the next tick to allow the update to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(editorElement.querySelector(".ProseMirror")?.innerHTML).toBe(
|
||||
"<p>A human💪</p>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JsonValue, RawCoList } from "cojson";
|
||||
import { ControlledAccount, RawAccount } from "cojson";
|
||||
import type { JsonValue, RawCoList, CoValueUniqueness, RawCoID } from "cojson";
|
||||
import { ControlledAccount, RawAccount, cojsonInternals } from "cojson";
|
||||
import { calcPatch } from "fast-myers-diff";
|
||||
import type {
|
||||
Account,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
RegisteredSchemas,
|
||||
SchemaInit,
|
||||
accessChildByKey,
|
||||
activeAccountContext,
|
||||
coField,
|
||||
coValueClassFromCoValueClassOrSchema,
|
||||
coValuesCache,
|
||||
@@ -236,12 +237,21 @@ export class CoList<out Item = any> extends Array<Item> implements CoValue {
|
||||
static create<L extends CoList>(
|
||||
this: CoValueClass<L>,
|
||||
items: L[number][],
|
||||
options?: { owner: Account | Group } | Account | Group,
|
||||
options?:
|
||||
| {
|
||||
owner: Account | Group;
|
||||
unique?: CoValueUniqueness["uniqueness"];
|
||||
}
|
||||
| Account
|
||||
| Group,
|
||||
) {
|
||||
const { owner } = parseCoValueCreateOptions(options);
|
||||
const { owner, uniqueness } = parseCoValueCreateOptions(options);
|
||||
const instance = new this({ init: items, owner });
|
||||
const raw = owner._raw.createList(
|
||||
toRawItems(items, instance._schema[ItemsSym], owner),
|
||||
null,
|
||||
"private",
|
||||
uniqueness,
|
||||
);
|
||||
|
||||
Object.defineProperties(instance, {
|
||||
@@ -546,6 +556,116 @@ export class CoList<out Item = any> extends Array<Item> implements CoValue {
|
||||
return cl.fromRaw(this._raw) as InstanceType<Cl>;
|
||||
}
|
||||
|
||||
/** @deprecated Use `CoList.upsertUnique` and `CoList.loadUnique` instead. */
|
||||
static findUnique<L extends CoList>(
|
||||
this: CoValueClass<L>,
|
||||
unique: CoValueUniqueness["uniqueness"],
|
||||
ownerID: ID<Account> | ID<Group>,
|
||||
as?: Account | Group | AnonymousJazzAgent,
|
||||
) {
|
||||
return CoList._findUnique(unique, ownerID, as);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
static _findUnique<L extends CoList>(
|
||||
this: CoValueClass<L>,
|
||||
unique: CoValueUniqueness["uniqueness"],
|
||||
ownerID: ID<Account> | ID<Group>,
|
||||
as?: Account | Group | AnonymousJazzAgent,
|
||||
) {
|
||||
as ||= activeAccountContext.get();
|
||||
|
||||
const header = {
|
||||
type: "colist" as const,
|
||||
ruleset: {
|
||||
type: "ownedByGroup" as const,
|
||||
group: ownerID as RawCoID,
|
||||
},
|
||||
meta: null,
|
||||
uniqueness: unique,
|
||||
};
|
||||
const crypto =
|
||||
as._type === "Anonymous" ? as.node.crypto : as._raw.core.node.crypto;
|
||||
return cojsonInternals.idforHeader(header, crypto) as ID<L>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given some data, updates an existing CoList or initialises a new one if none exists.
|
||||
*
|
||||
* Note: This method respects resolve options, and thus can return `null` if the references cannot be resolved.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const activeItems = await ItemList.upsertUnique(
|
||||
* {
|
||||
* value: [item1, item2, item3],
|
||||
* unique: sourceData.identifier,
|
||||
* owner: workspace,
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @param options The options for creating or loading the CoList. This includes the intended state of the CoList, its unique identifier, its owner, and the references to resolve.
|
||||
* @returns Either an existing & modified CoList, or a new initialised CoList if none exists.
|
||||
* @category Subscription & Loading
|
||||
*/
|
||||
static async upsertUnique<
|
||||
L extends CoList,
|
||||
const R extends RefsToResolve<L> = true,
|
||||
>(
|
||||
this: CoValueClass<L>,
|
||||
options: {
|
||||
value: L[number][];
|
||||
unique: CoValueUniqueness["uniqueness"];
|
||||
owner: Account | Group;
|
||||
resolve?: RefsToResolveStrict<L, R>;
|
||||
},
|
||||
): Promise<Resolved<L, R> | null> {
|
||||
let listId = CoList._findUnique(options.unique, options.owner.id);
|
||||
let list: Resolved<L, R> | null = await loadCoValueWithoutMe(this, listId, {
|
||||
...options,
|
||||
loadAs: options.owner._loadedAs,
|
||||
skipRetry: true,
|
||||
});
|
||||
if (!list) {
|
||||
list = (this as any).create(options.value, {
|
||||
owner: options.owner,
|
||||
unique: options.unique,
|
||||
}) as Resolved<L, R>;
|
||||
} else {
|
||||
(list as L).applyDiff(options.value);
|
||||
}
|
||||
|
||||
return await loadCoValueWithoutMe(this, listId, {
|
||||
...options,
|
||||
loadAs: options.owner._loadedAs,
|
||||
skipRetry: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a CoList by its unique identifier and owner's ID.
|
||||
* @param unique The unique identifier of the CoList to load.
|
||||
* @param ownerID The ID of the owner of the CoList.
|
||||
* @param options Additional options for loading the CoList.
|
||||
* @returns The loaded CoList, or null if unavailable.
|
||||
*/
|
||||
static loadUnique<L extends CoList, const R extends RefsToResolve<L> = true>(
|
||||
this: CoValueClass<L>,
|
||||
unique: CoValueUniqueness["uniqueness"],
|
||||
ownerID: ID<Account> | ID<Group>,
|
||||
options?: {
|
||||
resolve?: RefsToResolveStrict<L, R>;
|
||||
loadAs?: Account | AnonymousJazzAgent;
|
||||
},
|
||||
): Promise<Resolved<L, R> | null> {
|
||||
return loadCoValueWithoutMe(
|
||||
this,
|
||||
CoList._findUnique(unique, ownerID, options?.loadAs),
|
||||
{ ...options, skipRetry: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the `CoList` to be uploaded to the other peers.
|
||||
*
|
||||
|
||||
@@ -35,6 +35,7 @@ export type {
|
||||
TextPos,
|
||||
AccountClass,
|
||||
AccountCreationProps,
|
||||
BaseProfileShape,
|
||||
} from "./internal.js";
|
||||
|
||||
export {
|
||||
@@ -54,6 +55,7 @@ export {
|
||||
SubscriptionScope,
|
||||
exportCoValue,
|
||||
importContentPieces,
|
||||
Ref,
|
||||
} from "./internal.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -171,7 +171,9 @@ export function instantiateRefEncodedWithInit<V extends CoValue>(
|
||||
`Cannot automatically create CoValue from value: ${JSON.stringify(init)}. Use the CoValue schema's create() method instead.`,
|
||||
);
|
||||
}
|
||||
const owner = Group.create();
|
||||
const node = parentOwner._raw.core.node;
|
||||
const rawGroup = node.createGroup();
|
||||
const owner = new Group({ fromRaw: rawGroup });
|
||||
owner.addMember(parentOwner.castAs(Group));
|
||||
// @ts-expect-error - create is a static method in all CoValue classes
|
||||
return schema.ref.create(init, owner);
|
||||
|
||||
@@ -38,9 +38,14 @@ import {
|
||||
// Note: if you're editing this function, edit the `isAnyCoValueSchema`
|
||||
// function in `zodReExport.ts` as well
|
||||
export function isAnyCoValueSchema(
|
||||
schema: AnyZodOrCoValueSchema | CoValueClass,
|
||||
schema: unknown,
|
||||
): schema is AnyCoreCoValueSchema {
|
||||
return "collaborative" in schema && schema.collaborative === true;
|
||||
return (
|
||||
typeof schema === "object" &&
|
||||
schema !== null &&
|
||||
"collaborative" in schema &&
|
||||
schema.collaborative === true
|
||||
);
|
||||
}
|
||||
|
||||
export function isCoValueSchema(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user