Compare commits
17 Commits
jazz-react
...
briscola
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba7e20c0a | ||
|
|
d4c45fe2bd | ||
|
|
3a45c18cf5 | ||
|
|
79e3611897 | ||
|
|
9c50120110 | ||
|
|
447c7ace39 | ||
|
|
ffa53f2ab6 | ||
|
|
81bf8785cc | ||
|
|
304cb1bd15 | ||
|
|
eb3063b855 | ||
|
|
f0db269b0d | ||
|
|
48a8bd2c92 | ||
|
|
ff96a490f4 | ||
|
|
7834f8a672 | ||
|
|
969d3d5026 | ||
|
|
98cc5e1294 | ||
|
|
269673c15b |
5
.changeset/healthy-pianos-rest.md
Normal file
5
.changeset/healthy-pianos-rest.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"jazz-tools": patch
|
||||
---
|
||||
|
||||
Return field name on \_edits
|
||||
5
.changeset/loud-llamas-compare.md
Normal file
5
.changeset/loud-llamas-compare.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"jazz-tools": patch
|
||||
---
|
||||
|
||||
Simplify the .create calls by accepting directly "Account | Group" as second param
|
||||
52
.github/workflows/build-starters.yaml
vendored
52
.github/workflows/build-starters.yaml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Build Starters
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-starters:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
starter: [
|
||||
"react-demo-auth-tailwind",
|
||||
]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
- name: Pnpm Build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm turbo build;
|
||||
working-directory: ./starters/${{ matrix.starter }}
|
||||
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
project: ["tests/e2e", "examples/chat", "examples/file-share-svelte", "examples/form", "examples/music-player", "examples/pets", "examples/onboarding", "starters/react-demo-auth-tailwind"]
|
||||
project: ["tests/e2e", "examples/chat", "examples/file-share-svelte", "examples/form", "examples/music-player", "examples/pets", "examples/onboarding"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"**/ios/**",
|
||||
"**/android/**",
|
||||
"packages/jazz-svelte/**",
|
||||
"examples/*svelte*/**"
|
||||
"examples/*svelte*/**",
|
||||
"examples/briscola/src/routeTree.gen.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
36
examples/book-shelf/.gitignore
vendored
Normal file
36
examples/book-shelf/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
323
examples/book-shelf/CHANGELOG.md
Normal file
323
examples/book-shelf/CHANGELOG.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# jazz-example-book-shelf
|
||||
|
||||
## 0.1.38
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.50
|
||||
- jazz-tools@0.8.50
|
||||
- jazz-browser-media-images@0.8.50
|
||||
|
||||
## 0.1.37
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.49
|
||||
- jazz-tools@0.8.49
|
||||
- jazz-browser-media-images@0.8.49
|
||||
|
||||
## 0.1.36
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [635e824]
|
||||
- Updated dependencies [0a85982]
|
||||
- jazz-tools@0.8.48
|
||||
- jazz-browser-media-images@0.8.48
|
||||
- jazz-react@0.8.48
|
||||
|
||||
## 0.1.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [fa41f8e]
|
||||
- Updated dependencies [88d7d9a]
|
||||
- Updated dependencies [60e35ea]
|
||||
- jazz-tools@0.8.45
|
||||
- jazz-react@0.8.45
|
||||
- jazz-browser-media-images@0.8.45
|
||||
|
||||
## 0.1.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.44
|
||||
- jazz-tools@0.8.44
|
||||
- jazz-browser-media-images@0.8.44
|
||||
|
||||
## 0.1.33
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.41
|
||||
- jazz-tools@0.8.41
|
||||
- jazz-browser-media-images@0.8.41
|
||||
|
||||
## 0.1.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser-media-images@0.8.40
|
||||
- jazz-react@0.8.40
|
||||
|
||||
## 0.1.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [249eecb]
|
||||
- jazz-tools@0.8.39
|
||||
- jazz-browser-media-images@0.8.39
|
||||
- jazz-react@0.8.39
|
||||
|
||||
## 0.1.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.38
|
||||
- jazz-tools@0.8.38
|
||||
- jazz-browser-media-images@0.8.38
|
||||
|
||||
## 0.1.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.37
|
||||
- jazz-tools@0.8.37
|
||||
- jazz-browser-media-images@0.8.37
|
||||
|
||||
## 0.1.28
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [441fe27]
|
||||
- jazz-tools@0.8.36
|
||||
- jazz-react@0.8.36
|
||||
- jazz-browser-media-images@0.8.36
|
||||
|
||||
## 0.1.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser-media-images@0.8.35
|
||||
|
||||
## 0.1.26
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.34
|
||||
- jazz-tools@0.8.34
|
||||
- jazz-browser-media-images@0.8.34
|
||||
|
||||
## 0.1.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser-media-images@0.8.33
|
||||
- jazz-react@0.8.33
|
||||
|
||||
## 0.1.24
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [df42b2b]
|
||||
- jazz-tools@0.8.32
|
||||
- jazz-react@0.8.32
|
||||
- jazz-browser-media-images@0.8.32
|
||||
|
||||
## 0.1.23
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.31
|
||||
- jazz-tools@0.8.31
|
||||
- jazz-browser-media-images@0.8.31
|
||||
|
||||
## 0.1.22
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.30
|
||||
- jazz-tools@0.8.30
|
||||
- jazz-browser-media-images@0.8.30
|
||||
|
||||
## 0.1.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.29
|
||||
- jazz-tools@0.8.29
|
||||
- jazz-browser-media-images@0.8.29
|
||||
|
||||
## 0.1.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.28
|
||||
- jazz-tools@0.8.28
|
||||
- jazz-browser-media-images@0.8.28
|
||||
|
||||
## 0.1.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.27
|
||||
- jazz-tools@0.8.27
|
||||
- jazz-browser-media-images@0.8.27
|
||||
|
||||
## 0.1.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [59d37df]
|
||||
- jazz-react@0.8.26
|
||||
|
||||
## 0.1.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser-media-images@0.8.24
|
||||
- jazz-react@0.8.24
|
||||
|
||||
## 0.1.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d348c2d]
|
||||
- Updated dependencies [6902b5b]
|
||||
- Updated dependencies [1a0cd3d]
|
||||
- jazz-tools@0.8.23
|
||||
- jazz-react@0.8.23
|
||||
- jazz-browser-media-images@0.8.23
|
||||
|
||||
## 0.1.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [59cc64d]
|
||||
- jazz-react@0.8.22
|
||||
- jazz-browser-media-images@0.8.22
|
||||
|
||||
## 0.1.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [149ca97]
|
||||
- jazz-tools@0.8.21
|
||||
- jazz-react@0.8.21
|
||||
- jazz-browser-media-images@0.8.21
|
||||
|
||||
## 0.1.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd9b13f]
|
||||
- Updated dependencies [a69ed0b]
|
||||
- Updated dependencies [3ef3ff3]
|
||||
- Updated dependencies [c6931b8]
|
||||
- jazz-react@0.8.20
|
||||
- jazz-browser-media-images@0.8.20
|
||||
|
||||
## 0.1.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.19
|
||||
- jazz-tools@0.8.19
|
||||
- jazz-browser-media-images@0.8.19
|
||||
|
||||
## 0.1.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.18
|
||||
- jazz-tools@0.8.18
|
||||
- jazz-browser-media-images@0.8.18
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.17
|
||||
- jazz-tools@0.8.17
|
||||
- jazz-browser-media-images@0.8.17
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [2af107c]
|
||||
- jazz-react@0.8.16
|
||||
- jazz-browser-media-images@0.8.16
|
||||
- jazz-tools@0.8.16
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [cce679b]
|
||||
- jazz-tools@0.8.15
|
||||
- jazz-browser-media-images@0.8.15
|
||||
- jazz-react@0.8.15
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [36273b3]
|
||||
- jazz-tools@0.8.14
|
||||
- jazz-browser-media-images@0.8.14
|
||||
- jazz-react@0.8.14
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [fd011d7]
|
||||
- jazz-tools@0.8.13
|
||||
- jazz-browser-media-images@0.8.13
|
||||
- jazz-react@0.8.13
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cc6aee]
|
||||
- jazz-react@0.8.12
|
||||
- jazz-tools@0.8.12
|
||||
- jazz-browser-media-images@0.8.12
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.8.11
|
||||
- jazz-tools@0.8.11
|
||||
- jazz-browser-media-images@0.8.11
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser-media-images@0.8.7
|
||||
- jazz-react@0.8.7
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser-media-images@0.8.6
|
||||
- jazz-react@0.8.6
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c3f4e6b]
|
||||
- Updated dependencies [d9152ed]
|
||||
- jazz-tools@0.8.5
|
||||
- jazz-browser-media-images@0.8.5
|
||||
- jazz-react@0.8.5
|
||||
4
examples/book-shelf/Dockerfile
Normal file
4
examples/book-shelf/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/garden-co/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
41
examples/book-shelf/README.md
Normal file
41
examples/book-shelf/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Book shelf example with Jazz and Next.js
|
||||
|
||||
This is an example of a book shelf where you can add and review books.
|
||||
It shows you how to create and edit data, upload images, and share data publicly.
|
||||
|
||||
Live version: https://books-demo.jazz.tools
|
||||
|
||||
## Installing & running the example locally
|
||||
|
||||
(This requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
|
||||
|
||||
Start by downloading the [jazz repository](https://github.com/garden-co/jazz):
|
||||
```bash
|
||||
npx degit gardencmp/jazz jazz
|
||||
```
|
||||
|
||||
Go to the book-shelf example directory:
|
||||
```bash
|
||||
cd jazz/examples/book-shelf
|
||||
```
|
||||
|
||||
Install and build dependencies:
|
||||
```bash
|
||||
pnpm i && npx turbo build
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Cloud](https://jazz.tools/cloud) (`wss://cloud.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx jazz-run sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?peer=ws://localhost:4200`), or by setting the `sync` parameter of the `<Jazz.Provider>` provider component in [./src/components/JazzAndAuth.tsx](./src/components/JazzAndAuth.tsx).
|
||||
56
examples/book-shelf/job-template.nomad
Normal file
56
examples/book-shelf/job-template.nomad
Normal file
@@ -0,0 +1,56 @@
|
||||
job "example-bookShelf$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
group "static" {
|
||||
count = 4
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 80
|
||||
}
|
||||
}
|
||||
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "cloud"
|
||||
}
|
||||
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "$DOCKER_TAG"
|
||||
ports = ["http"]
|
||||
|
||||
auth = {
|
||||
username = "$DOCKER_USER"
|
||||
password = "$DOCKER_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "example-bookShelf$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 50 # MHz
|
||||
memory = 50 # MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# deploy bump 4
|
||||
5
examples/book-shelf/next-env.d.ts
vendored
Normal file
5
examples/book-shelf/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
4
examples/book-shelf/next.config.mjs
Normal file
4
examples/book-shelf/next.config.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
29
examples/book-shelf/package.json
Normal file
29
examples/book-shelf/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "jazz-example-book-shelf",
|
||||
"version": "0.1.38",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-browser-media-images": "workspace:0.8.50",
|
||||
"jazz-react": "workspace:0.8.50",
|
||||
"jazz-tools": "workspace:0.8.50",
|
||||
"next": "14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.6.2"
|
||||
}
|
||||
}
|
||||
8
examples/book-shelf/postcss.config.mjs
Normal file
8
examples/book-shelf/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
139
examples/book-shelf/src/app/add/page.tsx
Normal file
139
examples/book-shelf/src/app/add/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import { Container } from "@/components/Container";
|
||||
import { useAccount } from "@/components/JazzAndAuth";
|
||||
import RatingInput from "@/components/RatingInput";
|
||||
import { BookReview, ListOfBookReviews } from "@/schema";
|
||||
import { createImage } from "jazz-browser-media-images";
|
||||
import { Group, ImageDefinition } from "jazz-tools";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
export default function AddBookReview() {
|
||||
const { me } = useAccount();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [review, setReview] = useState("");
|
||||
const [rating, setRating] = useState(0);
|
||||
const [dateRead, setDateRead] = useState(
|
||||
new Date().toISOString().split("T")[0],
|
||||
);
|
||||
const [coverImage, setCoverImage] = useState<ImageDefinition | undefined>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const onDateChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const date = new Date(event.currentTarget.value)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
setDateRead(date);
|
||||
};
|
||||
|
||||
const onImageChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
const group = Group.create({ owner: me });
|
||||
group.addMember("everyone", "reader");
|
||||
|
||||
if (file) {
|
||||
createImage(file, { owner: group }).then((image) => {
|
||||
setCoverImage(image);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!me?.profile) return;
|
||||
|
||||
const bookReview = BookReview.create(
|
||||
{
|
||||
title,
|
||||
author,
|
||||
review,
|
||||
rating,
|
||||
dateRead: new Date(dateRead),
|
||||
cover: coverImage,
|
||||
},
|
||||
{
|
||||
owner: me.profile._owner,
|
||||
},
|
||||
);
|
||||
|
||||
if (!me.profile.bookReviews) {
|
||||
me.profile.bookReviews = ListOfBookReviews.create([], {
|
||||
owner: me.profile._owner,
|
||||
});
|
||||
}
|
||||
|
||||
me.profile.bookReviews.push(bookReview);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="grid max-w-lg gap-8 py-8">
|
||||
<h1 className="text-lg font-semibold text-black">Add book review</h1>
|
||||
<form action="" className="grid gap-4" onSubmit={onSubmit}>
|
||||
<label className="grid gap-1 text-sm text-gray-600">
|
||||
Cover
|
||||
<input type="file" onChange={onImageChange} />
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-gray-600">
|
||||
Title
|
||||
<input
|
||||
className="rounded border border-gray-300 px-2 py-1 shadow-sm"
|
||||
type="text"
|
||||
value={title}
|
||||
required
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
></input>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-gray-600">
|
||||
Author
|
||||
<input
|
||||
className="rounded border border-gray-300 px-2 py-1 shadow-sm"
|
||||
type="text"
|
||||
value={author}
|
||||
required
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
></input>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-gray-600">
|
||||
Date read
|
||||
<input
|
||||
className="rounded border border-gray-300 px-2 py-1 shadow-sm"
|
||||
type="date"
|
||||
value={dateRead}
|
||||
required
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-1 text-sm text-gray-600">
|
||||
Rating
|
||||
<RatingInput
|
||||
value={rating}
|
||||
onChange={(rating) => setRating(rating)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-1 text-sm text-gray-600">
|
||||
Review
|
||||
<textarea
|
||||
className="rounded border border-gray-300 px-2 py-1 shadow-sm"
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
></textarea>
|
||||
</label>
|
||||
<Button variant="primary" type="submit">
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
172
examples/book-shelf/src/app/book/[slug]/page.tsx
Normal file
172
examples/book-shelf/src/app/book/[slug]/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { BookCover } from "@/components/BookCover";
|
||||
import { Container } from "@/components/Container";
|
||||
import { useCoState } from "@/components/JazzAndAuth";
|
||||
import Rating from "@/components/Rating";
|
||||
import RatingInput from "@/components/RatingInput";
|
||||
import { BookReview } from "@/schema";
|
||||
import clsx from "clsx";
|
||||
import { Group, ID } from "jazz-tools";
|
||||
|
||||
const BookReviewTitle = ({
|
||||
bookReview,
|
||||
readOnly,
|
||||
}: {
|
||||
bookReview: BookReview;
|
||||
readOnly: boolean;
|
||||
}) => {
|
||||
const className = "font-serif text-2xl font-bold lg:text-4xl";
|
||||
|
||||
if (readOnly) {
|
||||
return <h1 className={className}>{bookReview.title}</h1>;
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
value={bookReview.title}
|
||||
placeholder="Book title"
|
||||
className={clsx(
|
||||
className,
|
||||
"w-full rounded border border-transparent px-2 py-1 hover:border-gray-300 hover:shadow-sm",
|
||||
)}
|
||||
onChange={(e) => (bookReview.title = e.target.value)}
|
||||
></input>
|
||||
);
|
||||
};
|
||||
|
||||
const BookReviewAuthor = ({
|
||||
bookReview,
|
||||
readOnly,
|
||||
}: {
|
||||
bookReview: BookReview;
|
||||
readOnly: boolean;
|
||||
}) => {
|
||||
const className = "text-gray-700";
|
||||
|
||||
if (readOnly) {
|
||||
return <p className={className}>by {bookReview.author}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
value={bookReview.author}
|
||||
placeholder="Author"
|
||||
className={clsx(
|
||||
className,
|
||||
"w-full rounded border border-transparent px-2 py-1 hover:border-gray-300 hover:shadow-sm",
|
||||
)}
|
||||
onChange={(e) => (bookReview.author = e.target.value)}
|
||||
></input>
|
||||
);
|
||||
};
|
||||
|
||||
const BookReviewDateRead = ({
|
||||
bookReview,
|
||||
readOnly,
|
||||
}: {
|
||||
bookReview: BookReview;
|
||||
readOnly: boolean;
|
||||
}) => {
|
||||
const className = "text-gray-700 max-w-[10rem]";
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
bookReview.dateRead && (
|
||||
<p className={className}>{bookReview.dateRead.toLocaleDateString()}</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={bookReview.dateRead?.toISOString().split("T")[0]}
|
||||
placeholder="Date read"
|
||||
className={clsx(
|
||||
className,
|
||||
"w-full rounded border border-transparent px-2 py-1 hover:border-gray-300 hover:shadow-sm",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const date = new Date(e.target.value);
|
||||
bookReview.dateRead = date;
|
||||
}}
|
||||
></input>
|
||||
);
|
||||
};
|
||||
|
||||
const BookReviewReview = ({
|
||||
bookReview,
|
||||
readOnly,
|
||||
}: {
|
||||
bookReview: BookReview;
|
||||
readOnly: boolean;
|
||||
}) => {
|
||||
const className = "text-sm leading-relaxed text-gray-600";
|
||||
|
||||
if (readOnly) {
|
||||
if (bookReview.review) {
|
||||
return <p className={className}>{bookReview.review}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={bookReview.review}
|
||||
placeholder="Write your review here..."
|
||||
className={clsx(
|
||||
className,
|
||||
"w-full rounded border border-transparent px-2 py-1 hover:border-gray-300 hover:shadow-sm",
|
||||
)}
|
||||
onChange={(e) => (bookReview.review = e.target.value)}
|
||||
></textarea>
|
||||
);
|
||||
};
|
||||
|
||||
const BookReviewRating = ({
|
||||
bookReview,
|
||||
readOnly,
|
||||
}: {
|
||||
bookReview: BookReview;
|
||||
readOnly: boolean;
|
||||
}) => {
|
||||
const className = "text-2xl sm:mx-0";
|
||||
|
||||
if (readOnly) {
|
||||
return <Rating className={className} rating={bookReview.rating} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RatingInput
|
||||
className={clsx(className, "p-2")}
|
||||
onChange={(rating) => (bookReview.rating = rating)}
|
||||
value={bookReview.rating}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
const bookReview = useCoState(BookReview, params.slug as ID<BookReview>);
|
||||
|
||||
if (!bookReview) return <></>;
|
||||
|
||||
const readOnly = !(bookReview._owner.castAs(Group).myRole() === "admin");
|
||||
|
||||
return (
|
||||
<Container className="grid gap-12 py-8">
|
||||
<div className="flex flex-col gap-6 sm:flex-row md:gap-10">
|
||||
<div className="w-[180px]">
|
||||
<BookCover bookReview={bookReview} readOnly={readOnly} />
|
||||
</div>
|
||||
|
||||
<div className="-mx-2 grid max-w-lg flex-1 gap-3 sm:mx-0">
|
||||
<BookReviewTitle bookReview={bookReview} readOnly={readOnly} />
|
||||
<BookReviewAuthor bookReview={bookReview} readOnly={readOnly} />
|
||||
<BookReviewDateRead bookReview={bookReview} readOnly={readOnly} />
|
||||
<BookReviewRating bookReview={bookReview} readOnly={readOnly} />
|
||||
<BookReviewReview bookReview={bookReview} readOnly={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
BIN
examples/book-shelf/src/app/favicon.ico
Normal file
BIN
examples/book-shelf/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
13
examples/book-shelf/src/app/globals.css
Normal file
13
examples/book-shelf/src/app/globals.css
Normal file
@@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@apply ring-2 ring-purple-600/75 dark:ring-2 dark:ring-white/75;
|
||||
}
|
||||
}
|
||||
39
examples/book-shelf/src/app/layout.tsx
Normal file
39
examples/book-shelf/src/app/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { JazzAndAuth } from "@/components/JazzAndAuth";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import clsx from "clsx";
|
||||
import { Fraunces } from "next/font/google";
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-fraunces",
|
||||
});
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jazz Book Shelf",
|
||||
description: "Jazz Book Shelf",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={clsx(inter.className, fraunces.variable)}>
|
||||
<JazzAndAuth>
|
||||
<header>
|
||||
<Nav />
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</JazzAndAuth>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
27
examples/book-shelf/src/app/page.tsx
Normal file
27
examples/book-shelf/src/app/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { useAccount } from "@/components/JazzAndAuth";
|
||||
import UserProfile from "@/components/UserProfile";
|
||||
import { JazzAccount } from "@/schema";
|
||||
import { ID } from "jazz-tools";
|
||||
|
||||
export default function Home() {
|
||||
const { me } = useAccount();
|
||||
|
||||
return (
|
||||
<Container className="grid gap-12 py-8">
|
||||
<UserProfile id={me?.id as ID<JazzAccount>} />
|
||||
|
||||
<label className="flex flex-wrap items-center gap-3">
|
||||
Share your profile:
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border p-1"
|
||||
value={`${window.location.origin}/user/${me?.id}`}
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
14
examples/book-shelf/src/app/user/[slug]/page.tsx
Normal file
14
examples/book-shelf/src/app/user/[slug]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Container } from "@/components/Container";
|
||||
import UserProfile from "@/components/UserProfile";
|
||||
import { JazzAccount } from "@/schema";
|
||||
import { ID } from "jazz-tools";
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
const { slug } = params;
|
||||
|
||||
return (
|
||||
<Container className="py-8">
|
||||
<UserProfile id={slug as ID<JazzAccount>} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
134
examples/book-shelf/src/components/BookCover.tsx
Normal file
134
examples/book-shelf/src/components/BookCover.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { useAccount } from "@/components/JazzAndAuth";
|
||||
import PlusIcon from "@/components/icons/PlusIcon";
|
||||
import { BookReview } from "@/schema";
|
||||
import clsx from "clsx";
|
||||
import { createImage } from "jazz-browser-media-images";
|
||||
import { ProgressiveImg } from "jazz-react";
|
||||
import { Group, ImageDefinition } from "jazz-tools";
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
|
||||
const BookCoverContainer = ({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx("h-[240px] lg:h-[260px]", className)}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MockCover = ({ bookReview }: { bookReview: BookReview }) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-l-sm rounded-r-md bg-gray-100 px-3 text-center shadow-lg">
|
||||
<p className="font-medium">{bookReview.title}</p>
|
||||
<p className="text-xs">{bookReview.author}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function BookCoverReadOnly({
|
||||
bookReview,
|
||||
className,
|
||||
}: {
|
||||
bookReview: BookReview;
|
||||
className?: string;
|
||||
}) {
|
||||
if (bookReview.cover) {
|
||||
return (
|
||||
<BookCoverContainer className={className}>
|
||||
<ProgressiveImg image={bookReview.cover}>
|
||||
{({ src }) => (
|
||||
<img
|
||||
className="max-h-full max-w-full rounded-l-sm rounded-r-md shadow-lg"
|
||||
src={src}
|
||||
/>
|
||||
)}
|
||||
</ProgressiveImg>
|
||||
</BookCoverContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BookCoverContainer className={className}>
|
||||
<MockCover bookReview={bookReview} />
|
||||
</BookCoverContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookCoverInput({ bookReview }: { bookReview: BookReview }) {
|
||||
const { me } = useAccount();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onImageChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!me?.profile) return;
|
||||
|
||||
const file = event.currentTarget.files?.[0];
|
||||
|
||||
if (file) {
|
||||
createImage(file, { owner: me.profile._owner }).then((image) => {
|
||||
bookReview.cover = image;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
bookReview.cover = null;
|
||||
};
|
||||
|
||||
if (bookReview.cover) {
|
||||
return (
|
||||
<div className="group relative inline-block">
|
||||
<BookCoverReadOnly
|
||||
className="transition-opacity group-hover:opacity-40"
|
||||
bookReview={bookReview}
|
||||
/>
|
||||
<div className="absolute left-0 top-0 hidden h-full w-full items-center justify-center rounded-l-sm rounded-r-md group-hover:flex">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
type="button"
|
||||
className="shadow"
|
||||
onClick={remove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BookCoverContainer className="flex w-[180px] flex-col justify-center rounded-l-sm rounded-r-md bg-gray-100 p-3 shadow-lg">
|
||||
<button
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-3 text-gray-500 transition-colors hover:text-gray-600"
|
||||
type="button"
|
||||
onClick={onUploadClick}
|
||||
>
|
||||
<PlusIcon className="h-10 w-10" />
|
||||
Upload book cover
|
||||
</button>
|
||||
<label className="sr-only">
|
||||
Cover
|
||||
<input ref={inputRef} type="file" onChange={onImageChange} />
|
||||
</label>
|
||||
</BookCoverContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookCover({
|
||||
bookReview,
|
||||
readOnly,
|
||||
}: {
|
||||
bookReview: BookReview;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
if (readOnly) return <BookCoverReadOnly bookReview={bookReview} />;
|
||||
|
||||
return <BookCoverInput bookReview={bookReview} />;
|
||||
}
|
||||
30
examples/book-shelf/src/components/BookReviewHeader.tsx
Normal file
30
examples/book-shelf/src/components/BookReviewHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BookCover } from "@/components/BookCover";
|
||||
import Rating from "@/components/Rating";
|
||||
import RatingInput from "@/components/RatingInput";
|
||||
import { BookReview } from "@/schema";
|
||||
import { Group } from "jazz-tools";
|
||||
|
||||
export function BookReviewHeader({ bookReview }: { bookReview: BookReview }) {
|
||||
const { title, author, rating, review, dateRead } = bookReview;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<div>
|
||||
<h1 className="mb-1 font-serif text-2xl font-bold md:mb-3 lg:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-500">by {author}</p>
|
||||
</div>
|
||||
|
||||
{bookReview._owner.castAs(Group).myRole() === "admin" ? (
|
||||
<RatingInput
|
||||
onChange={(rating) => (bookReview.rating = rating)}
|
||||
value={rating}
|
||||
/>
|
||||
) : (
|
||||
<Rating className="mx-auto text-2xl sm:mx-0" rating={rating} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
examples/book-shelf/src/components/BookReviewThumbnail.tsx
Normal file
35
examples/book-shelf/src/components/BookReviewThumbnail.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { BookCover, BookCoverReadOnly } from "@/components/BookCover";
|
||||
import { useCoState } from "@/components/JazzAndAuth";
|
||||
import StarIcon from "@/components/icons/StarIcon";
|
||||
import { ID } from "jazz-tools";
|
||||
import Link from "next/link";
|
||||
import { BookReview } from "../schema";
|
||||
|
||||
export function BookReviewThumbnail({ id }: { id: ID<BookReview> }) {
|
||||
const bookReview = useCoState(BookReview, id);
|
||||
|
||||
if (!bookReview) return <></>;
|
||||
|
||||
return (
|
||||
<div className="inline-flex shrink-0 gap-4 rounded border p-4 sm:block sm:space-y-6 sm:border-0 sm:p-0 md:w-[200px]">
|
||||
<Link href={`/book/${bookReview.id}`} className="sm:block sm:flex-1">
|
||||
<BookCoverReadOnly bookReview={bookReview} />
|
||||
</Link>
|
||||
|
||||
<div className="flex-1">
|
||||
<Link href={`/book/${bookReview.id}`}>
|
||||
<h2 className="mb-1 text-sm font-medium">{bookReview.title}</h2>
|
||||
</Link>
|
||||
<div className="mb-2 flex flex-col gap-2 text-sm text-gray-500 sm:flex-row sm:items-center">
|
||||
<p>{bookReview.author}</p>
|
||||
<div className="flex items-center gap-0.5 text-xs font-semibold leading-none">
|
||||
<StarIcon className="-mt-px text-base text-yellow-400" />
|
||||
{bookReview.rating}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
examples/book-shelf/src/components/Button.tsx
Normal file
65
examples/book-shelf/src/components/Button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
interface Props {
|
||||
variant?: "primary" | "secondary" | "tertiary";
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
interface AnchorProps
|
||||
extends Props,
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
}
|
||||
|
||||
type ButtonProps = ComponentProps<"button"> & Props;
|
||||
|
||||
export function Button(props: AnchorProps | ButtonProps) {
|
||||
const {
|
||||
className: customClassName = "",
|
||||
variant = "primary",
|
||||
children,
|
||||
size = "md",
|
||||
} = props;
|
||||
|
||||
const variantClassNames = {
|
||||
base: "inline-flex gap-2 items-center justify-center rounded-full overflow-hidden transition-colors",
|
||||
primary:
|
||||
"bg-purple-300 font-medium text-purple-950 px-4 py-2 rounded-full hover:bg-purple-200",
|
||||
secondary:
|
||||
"rounded-full bg-slate-100 font-medium text-slate-600 hover:bg-slate-200",
|
||||
tertiary: "rounded-full bg-white text-purple-950 font-medium",
|
||||
};
|
||||
|
||||
const sizeClassNames = {
|
||||
sm: "py-1.5 px-3 text-sm",
|
||||
md: "py-2 px-5",
|
||||
lg: "py-2 md:py-2.5 px-6 md:text-lg",
|
||||
};
|
||||
|
||||
const className = clsx(
|
||||
customClassName,
|
||||
variantClassNames.base,
|
||||
variantClassNames[variant],
|
||||
sizeClassNames[size],
|
||||
);
|
||||
|
||||
if (!!(props as AnchorProps).href) {
|
||||
const anchorProps = props as AnchorProps;
|
||||
return (
|
||||
<Link href={anchorProps.href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonProps = props as ButtonProps;
|
||||
|
||||
return (
|
||||
<button {...buttonProps} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
13
examples/book-shelf/src/components/Container.tsx
Normal file
13
examples/book-shelf/src/components/Container.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("mx-auto max-w-4xl px-4", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
29
examples/book-shelf/src/components/JazzAndAuth.tsx
Normal file
29
examples/book-shelf/src/components/JazzAndAuth.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { JazzAccount } from "@/schema";
|
||||
import { DemoAuthBasicUI, createJazzReactApp, useDemoAuth } from "jazz-react";
|
||||
|
||||
const Jazz = createJazzReactApp({
|
||||
AccountSchema: JazzAccount,
|
||||
});
|
||||
|
||||
export const { useAccount, useCoState } = Jazz;
|
||||
|
||||
export function JazzAndAuth({ children }: { children: React.ReactNode }) {
|
||||
const [auth, authState] = useDemoAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Jazz.Provider
|
||||
auth={auth}
|
||||
// replace `you@example.com` with your email as a temporary API key
|
||||
peer="wss://cloud.jazz.tools/?key=you@example.com"
|
||||
>
|
||||
{children}
|
||||
</Jazz.Provider>
|
||||
{authState.state !== "signedIn" && (
|
||||
<DemoAuthBasicUI appName="Jazz Book Shelf" state={authState} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
examples/book-shelf/src/components/Nav.tsx
Normal file
26
examples/book-shelf/src/components/Nav.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import { Container } from "@/components/Container";
|
||||
import { useAccount } from "@/components/JazzAndAuth";
|
||||
import Link from "next/link";
|
||||
|
||||
export function Nav() {
|
||||
const { me, logOut } = useAccount();
|
||||
|
||||
return (
|
||||
<nav className="border-b py-3">
|
||||
<Container className="flex items-center justify-between gap-12 text-sm">
|
||||
<Link href="/" className="font-serif text-lg font-semibold">
|
||||
Jazz Book Shelf
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<p>{me?.profile?.name}</p>
|
||||
<Button variant="secondary" onClick={logOut}>
|
||||
Log out
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
21
examples/book-shelf/src/components/Rating.tsx
Normal file
21
examples/book-shelf/src/components/Rating.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import StarIcon from "@/components/icons/StarIcon";
|
||||
import StarOutlineIcon from "@/components/icons/StarOutlineIcon";
|
||||
|
||||
export default function Rating({
|
||||
rating,
|
||||
className = "",
|
||||
}: {
|
||||
rating?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const max = 5;
|
||||
const outline = max - (rating || 0);
|
||||
return (
|
||||
<div className={`inline-flex gap-0.5 text-yellow-400 ${className}`}>
|
||||
{rating ? [...Array(rating)].map((x, i) => <StarIcon key={i} />) : <></>}
|
||||
{[...Array(outline)].map((x, i) => (
|
||||
<StarOutlineIcon key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
examples/book-shelf/src/components/RatingInput.tsx
Normal file
47
examples/book-shelf/src/components/RatingInput.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import StarIcon from "@/components/icons/StarIcon";
|
||||
import StarOutlineIcon from "@/components/icons/StarOutlineIcon";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface RatingInputProps {
|
||||
value?: number;
|
||||
onChange: (value: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function RatingInput({
|
||||
value = 0,
|
||||
onChange,
|
||||
className,
|
||||
}: RatingInputProps) {
|
||||
const handleChange = (newRating: number) => {
|
||||
onChange(newRating > 5 ? 5 : newRating);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "flex gap-0.5 text-2xl text-yellow-400")}>
|
||||
{[...Array(5)].map((_, i) => {
|
||||
return i < value ? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none"
|
||||
onClick={() => handleChange(i + 1)}
|
||||
key={i}
|
||||
>
|
||||
<StarIcon />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none"
|
||||
onClick={() => handleChange(i + 1)}
|
||||
key={i}
|
||||
>
|
||||
<StarOutlineIcon />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
examples/book-shelf/src/components/UserProfile.tsx
Normal file
39
examples/book-shelf/src/components/UserProfile.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { BookReviewThumbnail } from "@/components/BookReviewThumbnail";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useCoState } from "@/components/JazzAndAuth";
|
||||
import { JazzAccount, JazzProfile, ListOfBookReviews } from "@/schema";
|
||||
import { Group, ID } from "jazz-tools";
|
||||
|
||||
export default function UserProfile({ id }: { id: ID<JazzAccount> }) {
|
||||
const user = useCoState(JazzAccount, id);
|
||||
const profile = useCoState(JazzProfile, user?.profile?.id);
|
||||
|
||||
const bookReviews = useCoState(
|
||||
ListOfBookReviews,
|
||||
user?.profile?._refs.bookReviews?.id,
|
||||
[{}],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-serif text-lg font-medium sm:text-2xl">
|
||||
{profile?.name}'s book shelf
|
||||
</h1>
|
||||
{profile?._owner.castAs(Group).myRole() === "admin" && (
|
||||
<Button href="/add" variant="primary">
|
||||
Add book
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{bookReviews?.map((bookReview) => (
|
||||
<BookReviewThumbnail key={bookReview.id} id={bookReview.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export function LinkIcon() {
|
||||
export default function PlusIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -6,12 +6,14 @@ export function LinkIcon() {
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-3 h-3"
|
||||
className={className}
|
||||
width="1em"
|
||||
height="1em"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
18
examples/book-shelf/src/components/icons/StarIcon.tsx
Normal file
18
examples/book-shelf/src/components/icons/StarIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export default function StarIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
width="1em"
|
||||
height="1em"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
examples/book-shelf/src/components/icons/StarOutlineIcon.tsx
Normal file
20
examples/book-shelf/src/components/icons/StarOutlineIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export default function StarOutlineIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
width="1em"
|
||||
height="1em"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
35
examples/book-shelf/src/schema.ts
Normal file
35
examples/book-shelf/src/schema.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Account,
|
||||
CoList,
|
||||
CoMap,
|
||||
Encoders,
|
||||
ImageDefinition,
|
||||
Profile,
|
||||
co,
|
||||
} from "jazz-tools";
|
||||
|
||||
export class BookReview extends CoMap {
|
||||
title = co.string;
|
||||
author = co.string;
|
||||
rating = co.number;
|
||||
dateRead? = co.encoded(Encoders.Date);
|
||||
review? = co.string;
|
||||
cover? = co.ref(ImageDefinition, { optional: true });
|
||||
}
|
||||
|
||||
export class ListOfBookReviews extends CoList.Of(co.ref(BookReview)) {}
|
||||
|
||||
/** The profile is an app-specific per-user public `CoMap`
|
||||
* where you can store top-level objects for that user */
|
||||
export class JazzProfile extends Profile {
|
||||
bookReviews = co.ref(ListOfBookReviews);
|
||||
}
|
||||
|
||||
export class JazzAccount extends Account {
|
||||
profile = co.ref(JazzProfile);
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
migrate() {}
|
||||
}
|
||||
25
examples/book-shelf/tailwind.config.ts
Normal file
25
examples/book-shelf/tailwind.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
const colors = require("tailwindcss/colors");
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
serif: ["var(--font-fraunces)"],
|
||||
},
|
||||
colors: {
|
||||
purple: {
|
||||
...colors.purple,
|
||||
950: "#211f5a",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
@@ -3,27 +3,26 @@
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build": {
|
||||
"env": {
|
||||
"APP_NAME": "react-demo-auth-tailwind"
|
||||
"APP_NAME": "books-demo"
|
||||
}
|
||||
},
|
||||
"ignoreCommand": "node ../../ignore-vercel-build.js"
|
||||
2
examples/briscola/.env.example
Normal file
2
examples/briscola/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_JAZZ_WORKER_ACCOUNT=
|
||||
JAZZ_WORKER_SECRET=
|
||||
21
examples/briscola/.gitignore
vendored
Normal file
21
examples/briscola/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Local
|
||||
.DS_Store
|
||||
*.local
|
||||
*.log*
|
||||
|
||||
# Dist
|
||||
node_modules
|
||||
dist/
|
||||
.vinxi
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
|
||||
.env
|
||||
38
examples/briscola/README.md
Normal file
38
examples/briscola/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Jazz Briscola
|
||||
|
||||
This is a simplified implementation of the Italian card game [Briscola](https://en.wikipedia.org/wiki/Briscola), written using Jazz.
|
||||
|
||||
While most Jazz apps don't need workers, in this game players must not be able to see each other's cards. This is a good example of when a worker is useful. In this case, the worker acts as a dealer, revealing the cards to each player as needed.
|
||||
|
||||
In general this showcases how workers can be used to moderate access to coValues.
|
||||
|
||||
The communication between the dealer and the players is done using the [Inbox API](#), which is an abstraction over the Jazz API that allows for easy communication between workers and clients.
|
||||
|
||||
## Setup
|
||||
|
||||
First of we need to create a new account for the dealer:
|
||||
|
||||
```bash
|
||||
pnpx jazz-run account create --name "Dealer"
|
||||
```
|
||||
|
||||
This will print an account ID and a secret key:
|
||||
|
||||
```
|
||||
# Credentials for Jazz account "Dealer":
|
||||
JAZZ_WORKER_ACCOUNT=co_xxxx
|
||||
JAZZ_WORKER_SECRET=sealerSecret_xxx
|
||||
```
|
||||
use these to create a `.env` file based on the `.env.example` file and fill in the `VITE_JAZZ_WORKER_ACCOUNT` and `JAZZ_WORKER_SECRET` fields.
|
||||
|
||||
We can then start the dealer worker:
|
||||
|
||||
```bash
|
||||
pnpm dev:worker
|
||||
```
|
||||
|
||||
and the client:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
21
examples/briscola/components.json
Normal file
21
examples/briscola/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
12
examples/briscola/index.html
Normal file
12
examples/briscola/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="bg-green-800">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Briscola</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="min-h-screen"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
examples/briscola/package.json
Normal file
47
examples/briscola/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "jazz-example-briscola",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "vite --port=3001",
|
||||
"dev:worker": "tsx --env-file=.env ./src/worker.ts",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"start": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/router-plugin": "^1.87.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@tanstack/react-router": "^1.87.12",
|
||||
"@tanstack/router-devtools": "^1.87.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-nodejs": "workspace:*",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^11.14.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.2"
|
||||
}
|
||||
}
|
||||
119
examples/briscola/src/components/how-to-play-content.tsx
Normal file
119
examples/briscola/src/components/how-to-play-content.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
export function HowToPlayContent() {
|
||||
return (
|
||||
<div className="">
|
||||
<h3 className="font-semibold text-lg">Objective:</h3>
|
||||
<p className="mt-2">
|
||||
The goal is to score the most points by winning tricks containing
|
||||
high-value cards. The game is played until all card are played.
|
||||
</p>
|
||||
|
||||
<h3 className="font-semibold text-lg mt-6">The deck</h3>
|
||||
<p className="mt-2">
|
||||
A deck with 40 cards is used, split into four suits:
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="list-item">Coins (Denari)</li>
|
||||
<li className="list-item">Cups (Coppe)</li>
|
||||
<li className="list-item">Swords (Spade)</li>
|
||||
<li className="list-item">Clubs (Bastoni)</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p className="mt-2">Each suit has cards numbered from 1 to 10.</p>
|
||||
|
||||
<h3 className="font-semibold text-lg mt-6">Card values</h3>
|
||||
<p className="mt-2">
|
||||
Each card has a point value:
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="list-item">Ace (1): 11 points</li>
|
||||
<li className="list-item">Three (3): 10 points</li>
|
||||
<li className="list-item">Eight (8): 2 points</li>
|
||||
<li className="list-item">Nine (9): 3 points</li>
|
||||
<li className="list-item">Ten (10): 4 points</li>
|
||||
<li className="list-item">All others (2, 4-7): 0 points</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
There are <span className="font-semibold">120 total points</span> in the
|
||||
deck.
|
||||
</p>
|
||||
|
||||
<h3 className="font-semibold text-lg mt-6">Gameplay</h3>
|
||||
<p className="mt-2">
|
||||
<ol className="list-inside list-decimal">
|
||||
<li>
|
||||
<span className="font-semibold">Starting the game:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
3 cards are dealt to each player.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
1 card is placed face-up on the table, on the bottom of the draw
|
||||
pile, indicating the trump suit. (Briscola)
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
One player is randomly chosen to start the game.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Starting a trick:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
Players play one card each in turn, trying to win the trick.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Winning a trick:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
The highest card of the trump suit wins the trick.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
If no trump cards are played, the highest card of the leading
|
||||
suit wins.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
The leading suit is the suit of the first card played in the
|
||||
current trick.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
The winner collects the cards, which are placed face-up in their
|
||||
scoring pile.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Drawing cards:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
After each trick, a new card is dealt to each player (starting
|
||||
with the trick winner).
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Continuing play:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
The winner of the previous trick leads the next round.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">End of the game:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
Play continues until all cards are played.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
examples/briscola/src/components/playing-card.tsx
Normal file
75
examples/briscola/src/components/playing-card.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Card, Suit } from "@/schema";
|
||||
import type { co } from "jazz-tools";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import bastoni from "../img/bastoni.svg?url";
|
||||
import coppe from "../img/coppe.svg?url";
|
||||
import denari from "../img/denari.svg?url";
|
||||
import spade from "../img/spade.svg?url";
|
||||
|
||||
interface Props {
|
||||
card: co<Card>;
|
||||
faceDown?: boolean;
|
||||
className?: string;
|
||||
layoutId?: string;
|
||||
}
|
||||
export function PlayingCard({
|
||||
card,
|
||||
className,
|
||||
faceDown = false,
|
||||
layoutId,
|
||||
}: Props) {
|
||||
const cardImage = getCardImage(card.data?.suit!);
|
||||
if (!faceDown && card.data?.value === undefined && card.data?.suit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"block aspect-card w-[150px] bg-white touch-none rounded-lg shadow-lg p-2 border",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...(faceDown && {
|
||||
backgroundImage: `url(https://placecats.com/150/243)`,
|
||||
backgroundSize: "cover",
|
||||
}),
|
||||
}}
|
||||
layoutId={layoutId}
|
||||
>
|
||||
<div className="border-zinc-400 border rounded-lg h-full px-1 flex flex-col ">
|
||||
{!faceDown && (
|
||||
<>
|
||||
<div className="text-4xl font-bold text-black self-start">
|
||||
{card.data?.value}
|
||||
</div>
|
||||
<div className="grow flex justify-center items-center">
|
||||
<img
|
||||
src={cardImage}
|
||||
className="pointer-events-none max-h-[140px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-black rotate-180 transform self-end">
|
||||
{card.data?.value}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCardImage(suit: typeof Suit) {
|
||||
switch (suit) {
|
||||
case "C":
|
||||
return coppe;
|
||||
case "D":
|
||||
return denari;
|
||||
case "S":
|
||||
return spade;
|
||||
case "B":
|
||||
return bastoni;
|
||||
}
|
||||
}
|
||||
82
examples/briscola/src/components/ui/button.tsx
Normal file
82
examples/briscola/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
disabled,
|
||||
asChild = false,
|
||||
loading = false,
|
||||
loadingText,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
disabled={loading || disabled}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
86
examples/briscola/src/components/ui/card.tsx
Normal file
86
examples/briscola/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
120
examples/briscola/src/components/ui/dialog.tsx
Normal file
120
examples/briscola/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
22
examples/briscola/src/components/ui/input.tsx
Normal file
22
examples/briscola/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
24
examples/briscola/src/components/ui/label.tsx
Normal file
24
examples/briscola/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
29
examples/briscola/src/components/ui/separator.tsx
Normal file
29
examples/briscola/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
1
examples/briscola/src/constants.ts
Normal file
1
examples/briscola/src/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const WORKER_ID = import.meta.env.VITE_JAZZ_WORKER_ACCOUNT;
|
||||
7
examples/briscola/src/img/bastoni.svg
Normal file
7
examples/briscola/src/img/bastoni.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 66 KiB |
144
examples/briscola/src/img/coppe.svg
Normal file
144
examples/briscola/src/img/coppe.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 109 KiB |
95
examples/briscola/src/img/denari.svg
Normal file
95
examples/briscola/src/img/denari.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
86
examples/briscola/src/img/spade.svg
Normal file
86
examples/briscola/src/img/spade.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 69 KiB |
66
examples/briscola/src/index.css
Normal file
66
examples/briscola/src/index.css
Normal file
@@ -0,0 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
}
|
||||
25
examples/briscola/src/jazz.tsx
Normal file
25
examples/briscola/src/jazz.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { DemoAuthBasicUI, createJazzReactApp, useDemoAuth } from "jazz-react";
|
||||
|
||||
const Jazz = createJazzReactApp();
|
||||
export const {
|
||||
useAccount,
|
||||
useCoState,
|
||||
experimental: { useInboxSender },
|
||||
} = Jazz;
|
||||
|
||||
export function JazzAndAuth({ children }: { children: React.ReactNode }) {
|
||||
const [auth, authState] = useDemoAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Jazz.Provider
|
||||
auth={auth}
|
||||
// replace `you@example.com` with your email as a temporary API key
|
||||
peer="wss://cloud.jazz.tools/?key=you@example.com"
|
||||
>
|
||||
{children}
|
||||
</Jazz.Provider>
|
||||
<DemoAuthBasicUI appName="Briscola" state={authState} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
6
examples/briscola/src/lib/utils.ts
Normal file
6
examples/briscola/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
27
examples/briscola/src/main.tsx
Normal file
27
examples/briscola/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JazzAndAuth, useAccount } from "./jazz";
|
||||
import { router } from "./router";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const rootElement = document.getElementById("app")!;
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(<App />);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<JazzAndAuth>
|
||||
<Router />
|
||||
</JazzAndAuth>
|
||||
);
|
||||
}
|
||||
|
||||
function Router() {
|
||||
const { me } = useAccount();
|
||||
|
||||
return <RouterProvider router={router} context={{ me }} />;
|
||||
}
|
||||
207
examples/briscola/src/routeTree.gen.ts
Normal file
207
examples/briscola/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as HowToPlayImport } from './routes/how-to-play'
|
||||
import { Route as GameImport } from './routes/game'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as WaitingRoomWaitingRoomIdImport } from './routes/waiting-room.$waitingRoomId'
|
||||
import { Route as GameGameIdImport } from './routes/game/$gameId'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const HowToPlayRoute = HowToPlayImport.update({
|
||||
id: '/how-to-play',
|
||||
path: '/how-to-play',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const GameRoute = GameImport.update({
|
||||
id: '/game',
|
||||
path: '/game',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const WaitingRoomWaitingRoomIdRoute = WaitingRoomWaitingRoomIdImport.update({
|
||||
id: '/waiting-room/$waitingRoomId',
|
||||
path: '/waiting-room/$waitingRoomId',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const GameGameIdRoute = GameGameIdImport.update({
|
||||
id: '/$gameId',
|
||||
path: '/$gameId',
|
||||
getParentRoute: () => GameRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/game': {
|
||||
id: '/game'
|
||||
path: '/game'
|
||||
fullPath: '/game'
|
||||
preLoaderRoute: typeof GameImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/how-to-play': {
|
||||
id: '/how-to-play'
|
||||
path: '/how-to-play'
|
||||
fullPath: '/how-to-play'
|
||||
preLoaderRoute: typeof HowToPlayImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/game/$gameId': {
|
||||
id: '/game/$gameId'
|
||||
path: '/$gameId'
|
||||
fullPath: '/game/$gameId'
|
||||
preLoaderRoute: typeof GameGameIdImport
|
||||
parentRoute: typeof GameImport
|
||||
}
|
||||
'/waiting-room/$waitingRoomId': {
|
||||
id: '/waiting-room/$waitingRoomId'
|
||||
path: '/waiting-room/$waitingRoomId'
|
||||
fullPath: '/waiting-room/$waitingRoomId'
|
||||
preLoaderRoute: typeof WaitingRoomWaitingRoomIdImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
interface GameRouteChildren {
|
||||
GameGameIdRoute: typeof GameGameIdRoute
|
||||
}
|
||||
|
||||
const GameRouteChildren: GameRouteChildren = {
|
||||
GameGameIdRoute: GameGameIdRoute,
|
||||
}
|
||||
|
||||
const GameRouteWithChildren = GameRoute._addFileChildren(GameRouteChildren)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/game': typeof GameRouteWithChildren
|
||||
'/how-to-play': typeof HowToPlayRoute
|
||||
'/game/$gameId': typeof GameGameIdRoute
|
||||
'/waiting-room/$waitingRoomId': typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/game': typeof GameRouteWithChildren
|
||||
'/how-to-play': typeof HowToPlayRoute
|
||||
'/game/$gameId': typeof GameGameIdRoute
|
||||
'/waiting-room/$waitingRoomId': typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/game': typeof GameRouteWithChildren
|
||||
'/how-to-play': typeof HowToPlayRoute
|
||||
'/game/$gameId': typeof GameGameIdRoute
|
||||
'/waiting-room/$waitingRoomId': typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/game'
|
||||
| '/how-to-play'
|
||||
| '/game/$gameId'
|
||||
| '/waiting-room/$waitingRoomId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/game'
|
||||
| '/how-to-play'
|
||||
| '/game/$gameId'
|
||||
| '/waiting-room/$waitingRoomId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/game'
|
||||
| '/how-to-play'
|
||||
| '/game/$gameId'
|
||||
| '/waiting-room/$waitingRoomId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
GameRoute: typeof GameRouteWithChildren
|
||||
HowToPlayRoute: typeof HowToPlayRoute
|
||||
WaitingRoomWaitingRoomIdRoute: typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
GameRoute: GameRouteWithChildren,
|
||||
HowToPlayRoute: HowToPlayRoute,
|
||||
WaitingRoomWaitingRoomIdRoute: WaitingRoomWaitingRoomIdRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/game",
|
||||
"/how-to-play",
|
||||
"/waiting-room/$waitingRoomId"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/game": {
|
||||
"filePath": "game.tsx",
|
||||
"children": [
|
||||
"/game/$gameId"
|
||||
]
|
||||
},
|
||||
"/how-to-play": {
|
||||
"filePath": "how-to-play.tsx"
|
||||
},
|
||||
"/game/$gameId": {
|
||||
"filePath": "game/$gameId.tsx",
|
||||
"parent": "/game"
|
||||
},
|
||||
"/waiting-room/$waitingRoomId": {
|
||||
"filePath": "waiting-room.$waitingRoomId.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
||||
18
examples/briscola/src/router.tsx
Normal file
18
examples/briscola/src/router.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createRouter } from "@tanstack/react-router";
|
||||
|
||||
import { JazzAndAuth } from "./jazz";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
me: undefined!,
|
||||
},
|
||||
Wrap: JazzAndAuth,
|
||||
});
|
||||
20
examples/briscola/src/routes/__root.tsx
Normal file
20
examples/briscola/src/routes/__root.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { useAccount } from "@/jazz";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
|
||||
interface RouterContext {
|
||||
me: ReturnType<typeof useAccount>["me"];
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
examples/briscola/src/routes/game.tsx
Normal file
11
examples/briscola/src/routes/game.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/game")({
|
||||
beforeLoad: async ({ context: { me } }) => {
|
||||
if (!me) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
317
examples/briscola/src/routes/game/$gameId.tsx
Normal file
317
examples/briscola/src/routes/game/$gameId.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type Card, Game, PlayIntent, type Player } from "@/schema";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { Group, type ID } from "jazz-tools";
|
||||
import { AnimatePresence, LayoutGroup, Reorder, motion } from "motion/react";
|
||||
import type { FormEventHandler, ReactNode } from "react";
|
||||
|
||||
import { HowToPlayContent } from "@/components/how-to-play-content";
|
||||
import { PlayingCard } from "@/components/playing-card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { useCoState, useInboxSender } from "@/jazz";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/game/$gameId")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ params: { gameId }, context: { me } }) => {
|
||||
// !FIXME: this is useless, the layout takes care of this
|
||||
if (!me) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
});
|
||||
}
|
||||
const game = await Game.load(gameId as ID<Game>, me, {});
|
||||
|
||||
if (!game) {
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
// TODO: better loading screen
|
||||
pendingComponent: () => <div>...</div>,
|
||||
// TODO: better not found page
|
||||
notFoundComponent: () => <div>Game not found</div>,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { gameId } = Route.useParams();
|
||||
const playCard = useInboxSender(WORKER_ID);
|
||||
|
||||
const game = useCoState(Game, gameId as ID<Game>, {
|
||||
// TODO: load intent only for current user
|
||||
deck: [{}],
|
||||
playedCard: { data: {} },
|
||||
player1: {
|
||||
hand: [{ data: {}, meta: {} }],
|
||||
scoredCards: [{ data: {} }],
|
||||
account: {},
|
||||
},
|
||||
player2: {
|
||||
hand: [{ data: {}, meta: {} }],
|
||||
scoredCards: [{ data: {} }],
|
||||
account: {},
|
||||
},
|
||||
activePlayer: { account: {} },
|
||||
});
|
||||
|
||||
// TODO: loading
|
||||
if (!game) return null;
|
||||
|
||||
const [opponent, me] = game.player1.account.isMe
|
||||
? [game.player2, game.player1]
|
||||
: [game.player1, game.player2];
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!game.activePlayer.account.isMe) {
|
||||
alert("not your turn!");
|
||||
return;
|
||||
}
|
||||
|
||||
const playedCard = new FormData(e.target as HTMLFormElement).get(
|
||||
"play",
|
||||
) as string;
|
||||
|
||||
const playedCardValue: number = Number.parseInt(playedCard?.slice(1));
|
||||
const playedCardSuit: string = playedCard?.[0];
|
||||
|
||||
const pc = me.hand.find(
|
||||
(card) =>
|
||||
card.data?.value === playedCardValue &&
|
||||
card.data.suit === playedCardSuit,
|
||||
);
|
||||
if (!pc) {
|
||||
return;
|
||||
}
|
||||
|
||||
playCard(
|
||||
PlayIntent.create(
|
||||
{ type: "play", card: pc, game },
|
||||
{ owner: Group.create({ owner: me.account }) },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className="flex flex-col h-screen p-2 ">
|
||||
<PlayerArea player={opponent}>
|
||||
<ul className="flex gap-2 flex-row-reverse place-content-center ">
|
||||
<AnimatePresence>
|
||||
{opponent.hand.getSorted().map((card) => {
|
||||
if (!card) return null;
|
||||
|
||||
return (
|
||||
<motion.li key={card?.id} layout>
|
||||
<PlayingCard card={card} faceDown layoutId={card?.id} />
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
</PlayerArea>
|
||||
|
||||
<div className="container mx-auto grow items-center justify-center grid grid-cols-2">
|
||||
<div className="relative flex justify-center items-center">
|
||||
{game.deck[0] && (
|
||||
<PlayingCard
|
||||
className="rotate-[88deg] left-1/2 absolute"
|
||||
card={game.deck[0]}
|
||||
layoutId={`${game.deck[0]?.id}`}
|
||||
/>
|
||||
)}
|
||||
<CardStack cards={game.deck.slice(1)} faceDown />
|
||||
</div>
|
||||
|
||||
<div className="relative h-full items-center flex justify-center">
|
||||
<AnimatePresence>
|
||||
{game.playedCard && (
|
||||
<motion.div
|
||||
className="absolute"
|
||||
key={game.playedCard.id}
|
||||
animate={{
|
||||
rotate: 0,
|
||||
}}
|
||||
>
|
||||
<PlayingCard
|
||||
card={game.playedCard}
|
||||
layoutId={`${game.playedCard?.id}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<PlayerArea player={me}>
|
||||
<div className="">
|
||||
<RadioGroup.Root
|
||||
className="flex place-content-center"
|
||||
aria-label="Play card"
|
||||
orientation="horizontal"
|
||||
loop
|
||||
name="play"
|
||||
required
|
||||
asChild
|
||||
>
|
||||
<Reorder.Group
|
||||
axis="x"
|
||||
values={me.hand.getSorted()}
|
||||
onReorder={(cards) => {
|
||||
cards.forEach((card, i) => {
|
||||
if (!card?.meta) return;
|
||||
card.meta.index = i;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{me.hand
|
||||
.getSorted()
|
||||
.filter(
|
||||
(card) =>
|
||||
card?.data?.value !== undefined &&
|
||||
card.data.suit !== undefined,
|
||||
)
|
||||
.map((card, i, cards) => {
|
||||
if (!card) return null;
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={`${card?.data?.suit}${card?.data?.value}`}
|
||||
value={card}
|
||||
initial={{
|
||||
translateY: 800,
|
||||
}}
|
||||
animate={{
|
||||
rotate: i * 15 - (15 * (cards.length - 1)) / 2,
|
||||
translateY: 0,
|
||||
}}
|
||||
whileDrag={{ scale: 1.1 }}
|
||||
exit={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
layout
|
||||
layoutId={`${card?.id}`}
|
||||
>
|
||||
<RadioGroup.Item
|
||||
value={`${card?.data?.suit}${card?.data?.value}`}
|
||||
className="relative data-[state=checked]:translate-y-[-10px] data-[state=checked]:scale-110 data-[state=unchecked]:translate-y-0 transition-transform"
|
||||
asChild
|
||||
>
|
||||
<motion.button>
|
||||
<PlayingCard card={card} />
|
||||
</motion.button>
|
||||
</RadioGroup.Item>
|
||||
</Reorder.Item>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</PlayerArea>
|
||||
</form>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardStackProps {
|
||||
cards: Card[];
|
||||
className?: string;
|
||||
faceDown?: boolean;
|
||||
}
|
||||
|
||||
function CardStack({ cards, className, faceDown = false }: CardStackProps) {
|
||||
return (
|
||||
<div className={cn("relative w-[150px] h-[245px]", className)}>
|
||||
<AnimatePresence>
|
||||
{cards.map((card) => (
|
||||
<motion.div key={card?.id} className="absolute" animate layout>
|
||||
<PlayingCard card={card} faceDown={faceDown} layoutId={card?.id} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlayerAreaProps {
|
||||
player: Player;
|
||||
children: ReactNode;
|
||||
}
|
||||
function PlayerArea({ children, player }: PlayerAreaProps) {
|
||||
return (
|
||||
<div className={cn("flex", !player.account?.isMe && "flex-row-reverse")}>
|
||||
<div className="flex items-center w-1/3 justify-around flex-col">
|
||||
{player.account?.isMe && (
|
||||
<>
|
||||
<Button size="lg" type="submit">
|
||||
Play selected card
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button size="sm" type="button" variant="link">
|
||||
<InfoIcon />
|
||||
How to play?
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>How to play Briscola</DialogTitle>
|
||||
<DialogDescription>
|
||||
<HowToPlayContent />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-1/3">{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-center flex-col items-center w-1/3 gap-2",
|
||||
!player.account?.isMe && "flex-col-reverse",
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">
|
||||
Score:{" "}
|
||||
<span className="font-semibold">
|
||||
{getScore(player.scoredCards?.map((c) => c!) ?? [])}
|
||||
</span>
|
||||
</span>
|
||||
<CardStack cards={player.scoredCards?.map((c) => c!) ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getScore(cards: Card[]) {
|
||||
return cards.reduce((acc, card) => {
|
||||
switch (card.data?.value) {
|
||||
case 3:
|
||||
return acc + 10;
|
||||
case 1:
|
||||
return acc + 11;
|
||||
case 10:
|
||||
return acc + 4;
|
||||
case 9:
|
||||
return acc + 3;
|
||||
case 8:
|
||||
return acc + 2;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
38
examples/briscola/src/routes/how-to-play.tsx
Normal file
38
examples/briscola/src/routes/how-to-play.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { HowToPlayContent } from "@/components/how-to-play-content";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/how-to-play")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="flex flex-col w-full place-items-center justify-center p-2 min-h-screen">
|
||||
<Card className="max-w-screen-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>How to Play?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HowToPlayContent />
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button asChild variant="link">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
Play
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
examples/briscola/src/routes/index.tsx
Normal file
81
examples/briscola/src/routes/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { useAccount, useInboxSender } from "@/jazz";
|
||||
import { CreateGameRequest } from "@/schema";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Group } from "jazz-tools";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: HomeComponent,
|
||||
});
|
||||
|
||||
function HomeComponent() {
|
||||
const createGame = useInboxSender(WORKER_ID);
|
||||
const { me } = useAccount();
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onNewGameClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const waitingRoomId = await createGame(
|
||||
CreateGameRequest.create(
|
||||
{
|
||||
type: "createGame",
|
||||
},
|
||||
{ owner: Group.create({ owner: me }) },
|
||||
),
|
||||
);
|
||||
|
||||
if (!waitingRoomId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({ to: `/waiting-room/${waitingRoomId}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col w-full place-items-center justify-center p-2">
|
||||
<Card className="w-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to Jazz Briscola</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center p-4">
|
||||
<Button
|
||||
onClick={onNewGameClick}
|
||||
loading={isLoading}
|
||||
loadingText="Creating game..."
|
||||
className="w-full"
|
||||
>
|
||||
New Game
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<Button variant="link" asChild>
|
||||
<Link to="/how-to-play">How to play?</Link>
|
||||
</Button>
|
||||
<Button variant="link" asChild className="text-muted-foreground">
|
||||
<Link
|
||||
href="https://en.wikipedia.org/wiki/Briscola"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Briscola on Wikipedia
|
||||
<ExternalLinkIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
examples/briscola/src/routes/waiting-room.$waitingRoomId.tsx
Normal file
103
examples/briscola/src/routes/waiting-room.$waitingRoomId.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { JoinGameRequest, WaitingRoom } from "@/schema";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { Group, type ID, InboxSender } from "jazz-tools";
|
||||
import { ClipboardCopyIcon, Loader2Icon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Route = createFileRoute("/waiting-room/$waitingRoomId")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context: { me }, params: { waitingRoomId } }) => {
|
||||
if (!me) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
const waitingRoom = await WaitingRoom.load(
|
||||
waitingRoomId as ID<WaitingRoom>,
|
||||
me,
|
||||
{ account1: {}, account2: {}, game: {} },
|
||||
);
|
||||
|
||||
if (!waitingRoom) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
|
||||
// If the waiting room already has a game, redirect to the game
|
||||
if (waitingRoom?.game) {
|
||||
throw redirect({ to: `/game/${waitingRoom.game.id}` });
|
||||
}
|
||||
|
||||
if (!waitingRoom?.account1?.isMe) {
|
||||
const sender = await InboxSender.load<JoinGameRequest, WaitingRoom>(
|
||||
WORKER_ID,
|
||||
me,
|
||||
);
|
||||
sender.sendMessage(
|
||||
JoinGameRequest.create(
|
||||
{ type: "joinGame", waitingRoom },
|
||||
{ owner: Group.create({ owner: me }) },
|
||||
),
|
||||
);
|
||||
}
|
||||
return { waitingRoom };
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { waitingRoom } = Route.useLoaderData();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingRoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
return waitingRoom?.subscribe({ game: {} }, () => {
|
||||
if (waitingRoom.game) {
|
||||
navigate({ to: `/game/${waitingRoom.game.id}` });
|
||||
}
|
||||
});
|
||||
}, [waitingRoom]);
|
||||
|
||||
const onCopyClick = () => {
|
||||
navigator.clipboard.writeText(window.location.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col w-full place-items-center justify-center p-2">
|
||||
<Card className="w-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Loader2Icon className="animate-spin inline h-8 w-8 mr-2" />
|
||||
Waiting for opponent to join the game
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Share this link with your friend to join the game. The game will
|
||||
automatically start once they join.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex">
|
||||
<Input
|
||||
className="w-full border bg-muted rounded-e-none"
|
||||
readOnly
|
||||
value={`${window.location}`}
|
||||
/>
|
||||
<Button onClick={onCopyClick} className="rounded-s-none">
|
||||
<ClipboardCopyIcon className="w-5 h-5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
examples/briscola/src/schema.ts
Normal file
111
examples/briscola/src/schema.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Account, CoList, CoMap, SchemaUnion, co } from "jazz-tools";
|
||||
|
||||
export const CardValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
|
||||
|
||||
export const CardValue = co.literal(...CardValues);
|
||||
|
||||
export const Suits = ["S", "B", "C", "D"] as const;
|
||||
|
||||
export const Suit = co.literal(...Suits);
|
||||
|
||||
export class CardMeta extends CoMap {
|
||||
index = co.optional.number;
|
||||
}
|
||||
export class CardData extends CoMap {
|
||||
value = CardValue;
|
||||
suit = Suit;
|
||||
}
|
||||
|
||||
export class Card extends CoMap {
|
||||
data = co.ref(CardData);
|
||||
meta = co.optional.ref(CardMeta);
|
||||
}
|
||||
|
||||
export class CardList extends CoList.Of(co.ref(Card)) {
|
||||
getSorted() {
|
||||
return this.toSorted(
|
||||
(a, b) => (a?.meta?.index ?? 0) - (b?.meta?.index ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Player extends CoMap {
|
||||
account = co.ref(Account);
|
||||
hand = co.ref(CardList); // write Tavolo - read me - quando il Game mi da le carte le scrive qui, quando valida la giocata la toglie da qui
|
||||
scoredCards = co.ref(CardList); // write Tavolo - read everyone -
|
||||
}
|
||||
|
||||
export class Game extends CoMap {
|
||||
deck = co.ref(CardList);
|
||||
|
||||
briscola = co.literal(...Suits);
|
||||
/**
|
||||
* The card that was played in the current turn.
|
||||
*/
|
||||
playedCard = co.optional.ref(Card);
|
||||
|
||||
activePlayer = co.ref(Player);
|
||||
player1 = co.ref(Player);
|
||||
player2 = co.ref(Player);
|
||||
|
||||
/**
|
||||
* Given a player, returns the opponent in the current game.
|
||||
*/
|
||||
getOpponent(player: Player) {
|
||||
// TODO: player may be unrelated to this game
|
||||
const opponent =
|
||||
player.account?.id === this.player1?.account?.id
|
||||
? this.player2
|
||||
: this.player1;
|
||||
|
||||
if (!opponent) {
|
||||
throw new Error("Opponent not found");
|
||||
}
|
||||
|
||||
return opponent.ensureLoaded({
|
||||
account: {},
|
||||
hand: [{}],
|
||||
scoredCards: [{}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class GameList extends CoList.Of(co.ref(Game)) {}
|
||||
|
||||
export class WaitingRoom extends CoMap {
|
||||
account1 = co.ref(Account);
|
||||
account2 = co.optional.ref(Account);
|
||||
game = co.optional.ref(Game);
|
||||
}
|
||||
|
||||
class BaseInboxMessage extends CoMap {
|
||||
type = co.literal("play", "createGame", "joinGame");
|
||||
}
|
||||
|
||||
export class PlayIntent extends BaseInboxMessage {
|
||||
type = co.literal("play");
|
||||
card = co.ref(Card);
|
||||
game = co.ref(Game);
|
||||
}
|
||||
|
||||
export class CreateGameRequest extends BaseInboxMessage {
|
||||
type = co.literal("createGame");
|
||||
}
|
||||
|
||||
export class JoinGameRequest extends BaseInboxMessage {
|
||||
type = co.literal("joinGame");
|
||||
waitingRoom = co.ref(WaitingRoom);
|
||||
}
|
||||
|
||||
export const InboxMessage = SchemaUnion.Of<BaseInboxMessage>((raw) => {
|
||||
switch (raw.get("type")) {
|
||||
case "play":
|
||||
return PlayIntent;
|
||||
case "createGame":
|
||||
return CreateGameRequest;
|
||||
case "joinGame":
|
||||
return JoinGameRequest;
|
||||
default:
|
||||
throw new Error("Unknown request type");
|
||||
}
|
||||
});
|
||||
331
examples/briscola/src/worker.ts
Normal file
331
examples/briscola/src/worker.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Card,
|
||||
CardData,
|
||||
CardList,
|
||||
CardMeta,
|
||||
CardValues,
|
||||
Game,
|
||||
InboxMessage,
|
||||
JoinGameRequest,
|
||||
PlayIntent,
|
||||
Player,
|
||||
Suits,
|
||||
WaitingRoom,
|
||||
} from "@/schema";
|
||||
import { startWorker } from "jazz-nodejs";
|
||||
import { Account, Group, type ID } from "jazz-tools";
|
||||
|
||||
const {
|
||||
worker,
|
||||
experimental: { inbox },
|
||||
} = await startWorker({
|
||||
accountID: process.env.VITE_JAZZ_WORKER_ACCOUNT,
|
||||
syncServer: "wss://cloud.jazz.tools/?key=you@example.com",
|
||||
});
|
||||
|
||||
inbox.subscribe(
|
||||
InboxMessage,
|
||||
async (message, senderID) => {
|
||||
const playerAccount = await Account.load(senderID, worker, {});
|
||||
if (!playerAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "play":
|
||||
console.log("play message from", senderID);
|
||||
handlePlayIntent(senderID, message.castAs(PlayIntent));
|
||||
break;
|
||||
case "createGame":
|
||||
console.log("create game message from", senderID);
|
||||
|
||||
const waitingRoomGroup = Group.create({ owner: worker });
|
||||
waitingRoomGroup.addMember("everyone", "reader");
|
||||
const waitingRoom = WaitingRoom.create(
|
||||
{ account1: playerAccount },
|
||||
{ owner: waitingRoomGroup },
|
||||
);
|
||||
|
||||
console.log("waiting room created with id:", waitingRoom.id);
|
||||
|
||||
return waitingRoom;
|
||||
case "joinGame":
|
||||
console.log("join game message from", senderID);
|
||||
const joinGameRequest = message.castAs(JoinGameRequest);
|
||||
if (
|
||||
!joinGameRequest.waitingRoom ||
|
||||
!joinGameRequest.waitingRoom.account1
|
||||
) {
|
||||
console.error("No waiting room in join game request");
|
||||
return;
|
||||
}
|
||||
joinGameRequest.waitingRoom.account2 = playerAccount;
|
||||
|
||||
const game = await createGame({
|
||||
account1: joinGameRequest.waitingRoom.account1,
|
||||
account2: joinGameRequest.waitingRoom.account2,
|
||||
});
|
||||
console.log("game created with id:", game.id);
|
||||
|
||||
joinGameRequest.waitingRoom.game = game;
|
||||
return joinGameRequest.waitingRoom;
|
||||
}
|
||||
},
|
||||
{ retries: 3 },
|
||||
);
|
||||
|
||||
interface CreateGameParams {
|
||||
account1: Account;
|
||||
account2: Account;
|
||||
}
|
||||
async function createGame({ account1, account2 }: CreateGameParams) {
|
||||
const publicReadOnly = Group.create({ owner: worker });
|
||||
publicReadOnly.addMember(account1, "reader");
|
||||
publicReadOnly.addMember(account2, "reader");
|
||||
|
||||
const player1 = createPlayer({ account: account1 });
|
||||
const player2 = createPlayer({ account: account2 });
|
||||
|
||||
const deck = createDeck({ publicReadOnlyGroup: publicReadOnly });
|
||||
|
||||
while (player1.hand && player1.hand?.length < 3) {
|
||||
await drawCard(player1, deck);
|
||||
}
|
||||
|
||||
while (player2.hand && player2.hand?.length < 3) {
|
||||
await drawCard(player2, deck);
|
||||
}
|
||||
|
||||
const game = Game.create(
|
||||
{
|
||||
deck,
|
||||
briscola: deck[0]?.data?.suit!,
|
||||
activePlayer: player1,
|
||||
player1: player1,
|
||||
player2: player2,
|
||||
},
|
||||
{ owner: publicReadOnly },
|
||||
);
|
||||
|
||||
await game.waitForSync();
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
interface CreatePlayerParams {
|
||||
account: Account;
|
||||
}
|
||||
function createPlayer({ account }: CreatePlayerParams) {
|
||||
const publicRead = Group.create({ owner: worker });
|
||||
publicRead.addMember("everyone", "reader");
|
||||
|
||||
const player = Player.create(
|
||||
{
|
||||
scoredCards: CardList.create([], {
|
||||
owner: publicRead,
|
||||
}),
|
||||
account,
|
||||
hand: CardList.create([], { owner: publicRead }),
|
||||
},
|
||||
{ owner: publicRead },
|
||||
);
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
interface CreateDeckParams {
|
||||
publicReadOnlyGroup: Group;
|
||||
}
|
||||
function createDeck({ publicReadOnlyGroup }: CreateDeckParams) {
|
||||
const allCards = Suits.flatMap((suit) => {
|
||||
return CardValues.map((value) => {
|
||||
return { value, suit };
|
||||
});
|
||||
});
|
||||
shuffle(allCards);
|
||||
|
||||
const deck = CardList.create(
|
||||
allCards.map((card, i) => {
|
||||
const cardDataGroup = Group.create({ owner: worker });
|
||||
|
||||
return Card.create(
|
||||
{
|
||||
// The first card is the briscola, it should be visible by everyone,
|
||||
// so we make its owner the public read-only group.
|
||||
data: CardData.create(card, {
|
||||
owner: i === 0 ? publicReadOnlyGroup : cardDataGroup,
|
||||
}),
|
||||
},
|
||||
{ owner: publicReadOnlyGroup },
|
||||
);
|
||||
}),
|
||||
{ owner: publicReadOnlyGroup },
|
||||
);
|
||||
|
||||
return deck;
|
||||
}
|
||||
|
||||
async function drawCard(player: Player, deck: CardList) {
|
||||
const card = deck.pop();
|
||||
|
||||
const playerAccount = (await player.ensureLoaded({ account: {} }))?.account;
|
||||
|
||||
if (!playerAccount) {
|
||||
console.error("failed to load player account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (card) {
|
||||
const metaGroup = Group.create({ owner: worker });
|
||||
metaGroup.addMember("everyone", "reader");
|
||||
metaGroup.addMember(playerAccount, "writer");
|
||||
// Create the card meta. This is visible to everyone.
|
||||
// It's used to sort the cards in the UI.
|
||||
card.meta = CardMeta.create({}, { owner: metaGroup });
|
||||
// Add the player to the card's data group so that
|
||||
// the player can read the card data.
|
||||
card.data?._owner.castAs(Group).addMember(playerAccount, "reader");
|
||||
player.hand?.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Fisher–Yates shuffle
|
||||
function shuffle(array: unknown[]) {
|
||||
let currentIndex = array.length;
|
||||
|
||||
while (currentIndex) {
|
||||
const randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
|
||||
[array[currentIndex], array[randomIndex]] = [
|
||||
array[randomIndex],
|
||||
array[currentIndex],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function getCardValue(card: CardData) {
|
||||
switch (card.value) {
|
||||
case 1:
|
||||
return 20;
|
||||
case 3:
|
||||
return 15;
|
||||
default:
|
||||
return card.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayIntent(senderId: ID<Account>, playIntent: PlayIntent) {
|
||||
const playedCard = await playIntent.ensureLoaded({ card: { data: {} } });
|
||||
if (!playedCard) {
|
||||
console.log("No card in play intent");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await playIntent.ensureLoaded({
|
||||
game: {
|
||||
playedCard: { data: {} },
|
||||
deck: [{ data: {} }],
|
||||
activePlayer: { account: {} },
|
||||
player1: { hand: [{ data: {} }], scoredCards: [{}] },
|
||||
player2: { hand: [{ data: {} }], scoredCards: [{}] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!state?.game) {
|
||||
console.log("No game found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.game.activePlayer.account.id !== senderId) {
|
||||
console.log("Not player's turn");
|
||||
return;
|
||||
}
|
||||
|
||||
const publicReadOnly = state.game?.deck._owner.castAs(Group);
|
||||
|
||||
const player = state.game.activePlayer;
|
||||
const opponent = await state.game.getOpponent(player);
|
||||
|
||||
if (!opponent) {
|
||||
console.error("failed to get opponent");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"player",
|
||||
player.account?.id,
|
||||
"played",
|
||||
playedCard.card.data?.value,
|
||||
playedCard.card.data?.suit,
|
||||
);
|
||||
|
||||
const cardIndex =
|
||||
player.hand?.findIndex((card) => {
|
||||
return card?.id === playedCard.card.id;
|
||||
}) ?? -1;
|
||||
|
||||
if (cardIndex === -1) {
|
||||
console.log("Card not found in player's hand");
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the card from player's hand
|
||||
player.hand?.splice(cardIndex, 1);
|
||||
|
||||
// make the newly played card's data visible to everyone by extending its group with
|
||||
// the public read-only group so that everyone can see the card.
|
||||
const group = await playedCard.card.data._owner
|
||||
.castAs(Group)
|
||||
.ensureLoaded({});
|
||||
group?.extend(publicReadOnly);
|
||||
|
||||
// If there's already a card on the table, it means both players have played.
|
||||
if (state.game.playedCard) {
|
||||
// Check who's this turn's winner
|
||||
let winner: Player;
|
||||
// If both cards have the same suit, the one with the highest value wins
|
||||
if (state.game.playedCard.data?.suit === playedCard.card.data?.suit) {
|
||||
winner =
|
||||
getCardValue(playedCard.card.data) >
|
||||
getCardValue(state.game.playedCard.data)
|
||||
? player
|
||||
: opponent;
|
||||
} else {
|
||||
// else the active player wins only if they played a briscola.
|
||||
// (we already know the other player didn't)
|
||||
if (playedCard.card.data.suit === state.game.briscola) {
|
||||
winner = player;
|
||||
} else {
|
||||
winner = opponent;
|
||||
}
|
||||
}
|
||||
|
||||
// Put the cards in the winner's scored cards pile.
|
||||
winner.scoredCards?.push(state.game.playedCard, playedCard.card);
|
||||
|
||||
// The winner of the round always draws first.
|
||||
if (state.game.deck.length > 0) {
|
||||
drawCard(winner, state.game.deck);
|
||||
|
||||
const opponent = await state.game.getOpponent(winner);
|
||||
if (!opponent) {
|
||||
console.error("failed to get opponent");
|
||||
return;
|
||||
}
|
||||
drawCard(opponent, state.game.deck);
|
||||
}
|
||||
|
||||
// @ts-expect-error types are wonky
|
||||
state.game.activePlayer = winner;
|
||||
// And finally, remove the played card from the table.
|
||||
delete state.game.playedCard;
|
||||
|
||||
// TODO: if there are no more cards in the deck and both players have played all their cards, end the game.
|
||||
} else {
|
||||
// else, just put the card on the table and switch the active player.
|
||||
state.game.playedCard = playedCard.card;
|
||||
|
||||
state.game.activePlayer = opponent;
|
||||
}
|
||||
}
|
||||
61
examples/briscola/tailwind.config.js
Normal file
61
examples/briscola/tailwind.config.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
1: "hsl(var(--chart-1))",
|
||||
2: "hsl(var(--chart-2))",
|
||||
3: "hsl(var(--chart-3))",
|
||||
4: "hsl(var(--chart-4))",
|
||||
5: "hsl(var(--chart-5))",
|
||||
},
|
||||
},
|
||||
|
||||
aspectRatio: {
|
||||
card: "2584/4181",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
14
examples/briscola/tsconfig.json
Normal file
14
examples/briscola/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
examples/briscola/vite.config.ts
Normal file
14
examples/briscola/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { resolve } from "path";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite({}), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,156 +1,5 @@
|
||||
# chat-rn-clerk
|
||||
|
||||
## 1.0.56
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7491711]
|
||||
- jazz-tools@0.9.15
|
||||
- jazz-react-native@0.9.15
|
||||
- jazz-react-native-auth-clerk@0.9.15
|
||||
- jazz-react-native-media-images@0.9.15
|
||||
|
||||
## 1.0.55
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3df93cc]
|
||||
- jazz-tools@0.9.14
|
||||
- jazz-react-native@0.9.14
|
||||
- jazz-react-native-auth-clerk@0.9.14
|
||||
- jazz-react-native-media-images@0.9.14
|
||||
|
||||
## 1.0.54
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.13
|
||||
- jazz-react-native-auth-clerk@0.9.13
|
||||
- jazz-tools@0.9.13
|
||||
- jazz-react-native-media-images@0.9.13
|
||||
|
||||
## 1.0.53
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.12
|
||||
- jazz-react-native-auth-clerk@0.9.12
|
||||
- jazz-tools@0.9.12
|
||||
- jazz-react-native-media-images@0.9.12
|
||||
|
||||
## 1.0.52
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.11
|
||||
- jazz-react-native-auth-clerk@0.9.11
|
||||
- jazz-tools@0.9.11
|
||||
- jazz-react-native-media-images@0.9.11
|
||||
|
||||
## 1.0.51
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f76274c: Fix image handling in react-native
|
||||
- Updated dependencies [f76274c]
|
||||
- Updated dependencies [5e83864]
|
||||
- jazz-react-native@0.9.10
|
||||
- jazz-tools@0.9.10
|
||||
- jazz-react-native-auth-clerk@0.9.10
|
||||
- jazz-react-native-media-images@0.9.10
|
||||
|
||||
## 1.0.50
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8eb9247]
|
||||
- jazz-tools@0.9.9
|
||||
- jazz-react-native@0.9.9
|
||||
- jazz-react-native-auth-clerk@0.9.9
|
||||
- jazz-react-native-media-images@0.9.9
|
||||
|
||||
## 1.0.49
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d1d773b]
|
||||
- jazz-tools@0.9.8
|
||||
- jazz-react-native@0.9.8
|
||||
- jazz-react-native-auth-clerk@0.9.8
|
||||
- jazz-react-native-media-images@0.9.8
|
||||
|
||||
## 1.0.48
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8a390d2]
|
||||
- jazz-react-native@0.9.6
|
||||
- jazz-react-native-auth-clerk@0.9.6
|
||||
|
||||
## 1.0.47
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c871912]
|
||||
- jazz-react-native@0.9.5
|
||||
- jazz-react-native-auth-clerk@0.9.5
|
||||
|
||||
## 1.0.46
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.4
|
||||
- jazz-react-native-auth-clerk@0.9.4
|
||||
|
||||
## 1.0.45
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7cd691f]
|
||||
- jazz-react-native@0.9.3
|
||||
- jazz-react-native-auth-clerk@0.9.3
|
||||
|
||||
## 1.0.44
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [80fd3e9]
|
||||
- jazz-react-native@0.9.2
|
||||
- jazz-react-native-auth-clerk@0.9.2
|
||||
|
||||
## 1.0.43
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1b71969]
|
||||
- jazz-tools@0.9.1
|
||||
- jazz-react-native@0.9.1
|
||||
- jazz-react-native-auth-clerk@0.9.1
|
||||
- jazz-react-native-media-images@0.9.1
|
||||
|
||||
## 1.0.42
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1da4d55]
|
||||
- Updated dependencies [8eda792]
|
||||
- Updated dependencies [1e5e3a1]
|
||||
- jazz-react-native@0.9.0
|
||||
- jazz-tools@0.9.0
|
||||
- jazz-react-native-auth-clerk@0.9.0
|
||||
- jazz-react-native-media-images@0.9.0
|
||||
|
||||
## 1.0.41
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dc62b95]
|
||||
- Updated dependencies [1de26f8]
|
||||
- jazz-tools@0.8.51
|
||||
- jazz-react-native@0.8.51
|
||||
- jazz-react-native-auth-clerk@0.8.51
|
||||
- jazz-react-native-media-images@0.8.51
|
||||
|
||||
## 1.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -37,13 +37,7 @@
|
||||
],
|
||||
"expo-secure-store",
|
||||
"expo-font",
|
||||
"expo-router",
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "The app accesses your photos to let you share them with your friends."
|
||||
}
|
||||
]
|
||||
"expo-router"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useAccount, useCoState } from "@/src/jazz";
|
||||
import { Chat, Message } from "@/src/schema";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||
import clsx from "clsx";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAccount, useCoState } from "jazz-react-native";
|
||||
import { ProgressiveImg } from "jazz-react-native";
|
||||
import { createImage } from "jazz-react-native-media-images";
|
||||
import { Group, ID } from "jazz-tools";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import React, {
|
||||
@@ -19,8 +16,6 @@ import React, {
|
||||
KeyboardAvoidingView,
|
||||
TextInput,
|
||||
Button,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
|
||||
export default function Conversation() {
|
||||
@@ -30,7 +25,6 @@ export default function Conversation() {
|
||||
const [message, setMessage] = useState("");
|
||||
const loadedChat = useCoState(Chat, chat?.id, [{}]);
|
||||
const navigation = useNavigation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat) return;
|
||||
@@ -87,32 +81,6 @@ export default function Conversation() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
base64: true,
|
||||
quality: 0.7,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0].base64 && chat) {
|
||||
setIsUploading(true);
|
||||
const base64Uri = `data:image/jpeg;base64,${result.assets[0].base64}`;
|
||||
|
||||
const image = await createImage(base64Uri, {
|
||||
owner: chat._owner,
|
||||
maxSize: 2048,
|
||||
});
|
||||
|
||||
chat.push(Message.create({ text: "", image }, { owner: chat._owner }));
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to upload image");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageItem = ({ item }: { item: Message }) => {
|
||||
const isMe = item._edits.text.by?.isMe;
|
||||
return (
|
||||
@@ -138,27 +106,14 @@ export default function Conversation() {
|
||||
isMe ? "flex-row" : "flex-row",
|
||||
)}
|
||||
>
|
||||
{item.image && (
|
||||
<ProgressiveImg image={item.image} maxWidth={1024}>
|
||||
{({ src, res, originalSize }) => (
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
className="w-48 h-48 rounded-lg mb-2"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
)}
|
||||
</ProgressiveImg>
|
||||
)}
|
||||
{item.text && (
|
||||
<Text
|
||||
className={clsx(
|
||||
!isMe ? "text-black" : "text-gray-200",
|
||||
`text-md max-w-[85%]`,
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
className={clsx(
|
||||
!isMe ? "text-black" : "text-gray-200",
|
||||
`text-md max-w-[85%]`,
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</Text>
|
||||
<Text
|
||||
className={clsx(
|
||||
"text-[10px] text-right ml-2",
|
||||
@@ -192,17 +147,6 @@ export default function Conversation() {
|
||||
className="p-3 bg-white border-t border-gray-300"
|
||||
>
|
||||
<SafeAreaView className="flex-row items-center gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={handleImageUpload}
|
||||
disabled={isUploading}
|
||||
className="h-10 w-10 items-center justify-center"
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator size="small" color="#0000ff" />
|
||||
) : (
|
||||
<Text className="text-2xl">🖼️</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
className="flex-1 rounded-full h-10 px-4 bg-gray-100 border border-gray-300 focus:border-blue-500 focus:bg-white"
|
||||
value={message}
|
||||
|
||||
@@ -11,7 +11,7 @@ import React, {
|
||||
} from "react-native";
|
||||
|
||||
import { useUser } from "@clerk/clerk-expo";
|
||||
import { useAccount } from "jazz-react-native";
|
||||
import { useAccount } from "../../src/jazz";
|
||||
import { Chat } from "../../src/schema";
|
||||
|
||||
export default function ChatScreen() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chat-rn-clerk",
|
||||
"main": "index.js",
|
||||
"version": "1.0.56",
|
||||
"version": "1.0.40",
|
||||
"scripts": {
|
||||
"build": "expo export -p ios",
|
||||
"start": "expo start",
|
||||
@@ -20,7 +20,6 @@
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.11",
|
||||
"@clerk/clerk-expo": "^2.2.21",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@op-engineering/op-sqlite": "^11.2.12",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-navigation/native": "^7.0.13",
|
||||
"@react-navigation/native-stack": "^7.1.14",
|
||||
@@ -35,7 +34,6 @@
|
||||
"expo-dev-client": "~5.0.5",
|
||||
"expo-file-system": "^18.0.4",
|
||||
"expo-font": "~13.0.1",
|
||||
"expo-image-picker": "~16.0.4",
|
||||
"expo-linking": "~7.0.3",
|
||||
"expo-router": "~4.0.11",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useClerk, useUser } from "@clerk/clerk-expo";
|
||||
import { JazzProvider, setupKvStore } from "jazz-react-native";
|
||||
import { useJazzClerkAuth } from "jazz-react-native-auth-clerk";
|
||||
import React, { createContext, PropsWithChildren, useContext } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Jazz, kvStore } from "./jazz";
|
||||
|
||||
const AuthContext = createContext<{
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -15,13 +22,19 @@ export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
const kvStore = setupKvStore();
|
||||
|
||||
export function JazzAndAuth({ children }: PropsWithChildren) {
|
||||
const { isSignedIn, isLoaded: isClerkLoaded } = useUser();
|
||||
const clerk = useClerk();
|
||||
const [auth, state] = useJazzClerkAuth(clerk, kvStore);
|
||||
const isAuthenticated = Boolean(isSignedIn && isClerkLoaded && auth);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSignedIn && isClerkLoaded && auth) {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}, [isSignedIn, isClerkLoaded, auth]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -34,13 +47,13 @@ export function JazzAndAuth({ children }: PropsWithChildren) {
|
||||
</View>
|
||||
))}
|
||||
{auth && clerk.user ? (
|
||||
<JazzProvider
|
||||
<Jazz.Provider
|
||||
auth={auth}
|
||||
storage="sqlite"
|
||||
peer="wss://cloud.jazz.tools/?key=chat-rn-clerk-example-jazz@garden.co"
|
||||
storage={undefined}
|
||||
>
|
||||
{children}
|
||||
</JazzProvider>
|
||||
</Jazz.Provider>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
4
examples/chat-rn-clerk/src/jazz.tsx
Normal file
4
examples/chat-rn-clerk/src/jazz.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createJazzRNApp } from "jazz-react-native";
|
||||
|
||||
export const Jazz = createJazzRNApp();
|
||||
export const { useAccount, useCoState, useAcceptInvite, kvStore } = Jazz;
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CoList, CoMap, ImageDefinition, co } from "jazz-tools";
|
||||
import { CoList, CoMap, co } from "jazz-tools";
|
||||
|
||||
export class Message extends CoMap {
|
||||
text = co.string;
|
||||
image = co.optional.ref(ImageDefinition);
|
||||
}
|
||||
|
||||
export class Chat extends CoList.Of(co.ref(Message)) {}
|
||||
|
||||
@@ -1,128 +1,5 @@
|
||||
# chat-rn
|
||||
|
||||
## 1.0.53
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7491711]
|
||||
- jazz-tools@0.9.15
|
||||
- jazz-react-native@0.9.15
|
||||
|
||||
## 1.0.52
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3df93cc]
|
||||
- jazz-tools@0.9.14
|
||||
- jazz-react-native@0.9.14
|
||||
|
||||
## 1.0.51
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.13
|
||||
- jazz-tools@0.9.13
|
||||
|
||||
## 1.0.50
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.12
|
||||
- jazz-tools@0.9.12
|
||||
|
||||
## 1.0.49
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.11
|
||||
- jazz-tools@0.9.11
|
||||
|
||||
## 1.0.48
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f76274c]
|
||||
- Updated dependencies [5e83864]
|
||||
- jazz-react-native@0.9.10
|
||||
- jazz-tools@0.9.10
|
||||
|
||||
## 1.0.47
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8eb9247]
|
||||
- jazz-tools@0.9.9
|
||||
- jazz-react-native@0.9.9
|
||||
|
||||
## 1.0.46
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d1d773b]
|
||||
- jazz-tools@0.9.8
|
||||
- jazz-react-native@0.9.8
|
||||
|
||||
## 1.0.45
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8a390d2]
|
||||
- jazz-react-native@0.9.6
|
||||
|
||||
## 1.0.44
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c871912]
|
||||
- jazz-react-native@0.9.5
|
||||
|
||||
## 1.0.43
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.9.4
|
||||
|
||||
## 1.0.42
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7cd691f]
|
||||
- jazz-react-native@0.9.3
|
||||
|
||||
## 1.0.41
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [80fd3e9]
|
||||
- jazz-react-native@0.9.2
|
||||
|
||||
## 1.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1b71969]
|
||||
- jazz-tools@0.9.1
|
||||
- jazz-react-native@0.9.1
|
||||
|
||||
## 1.0.39
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1da4d55]
|
||||
- Updated dependencies [8eda792]
|
||||
- Updated dependencies [1e5e3a1]
|
||||
- jazz-react-native@0.9.0
|
||||
- jazz-tools@0.9.0
|
||||
|
||||
## 1.0.38
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dc62b95]
|
||||
- Updated dependencies [1de26f8]
|
||||
- jazz-tools@0.8.51
|
||||
- jazz-react-native@0.8.51
|
||||
|
||||
## 1.0.37
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-rn",
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.37",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "expo export -p ios",
|
||||
@@ -13,7 +13,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/core-asynciterator-polyfill": "^1.0.2",
|
||||
"@op-engineering/op-sqlite": "^11.2.12",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-navigation/native": "^7.0.13",
|
||||
"@react-navigation/native-stack": "^7.1.14",
|
||||
|
||||
@@ -9,8 +9,9 @@ import * as Linking from "expo-linking";
|
||||
import React, { StrictMode, useEffect, useState } from "react";
|
||||
import HandleInviteScreen from "./invite";
|
||||
|
||||
import { DemoAuthBasicUI, JazzProvider, useDemoAuth } from "jazz-react-native";
|
||||
import { DemoAuthBasicUI, useDemoAuth } from "jazz-react-native";
|
||||
import ChatScreen from "./chat";
|
||||
import { Jazz } from "./jazz";
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
@@ -33,6 +34,7 @@ function App() {
|
||||
"ChatScreen" | "HandleInviteScreen"
|
||||
>("ChatScreen");
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
Linking.getInitialURL().then((url) => {
|
||||
if (url) {
|
||||
@@ -49,10 +51,10 @@ function App() {
|
||||
|
||||
return (
|
||||
<StrictMode>
|
||||
<JazzProvider
|
||||
<Jazz.Provider
|
||||
auth={auth}
|
||||
storage="sqlite"
|
||||
peer="wss://cloud.jazz.tools/?key=chat-rn-example-jazz@garden.co"
|
||||
storage={undefined}
|
||||
>
|
||||
<NavigationContainer linking={linking} ref={navigationRef}>
|
||||
<Stack.Navigator initialRouteName={initialRoute}>
|
||||
@@ -68,7 +70,7 @@ function App() {
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</JazzProvider>
|
||||
</Jazz.Provider>
|
||||
{state.state !== "signedIn" ? (
|
||||
<DemoAuthBasicUI appName="Jazz Chat" state={state} />
|
||||
) : null}
|
||||
|
||||
@@ -14,7 +14,7 @@ import React, {
|
||||
Alert,
|
||||
} from "react-native";
|
||||
|
||||
import { useAccount, useCoState } from "jazz-react-native";
|
||||
import { useAccount, useCoState } from "./jazz";
|
||||
import { Chat, Message } from "./schema";
|
||||
|
||||
export default function ChatScreen({ navigation }: { navigation: any }) {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useAcceptInvite } from "jazz-react-native";
|
||||
import React, { Text } from "react-native";
|
||||
import { useAcceptInvite } from "./jazz";
|
||||
import { Chat } from "./schema";
|
||||
|
||||
type ChatScreenParams = {
|
||||
valueHint?: string;
|
||||
valueID?: string;
|
||||
inviteSecret?: string;
|
||||
};
|
||||
|
||||
export default function HandleInviteScreen({
|
||||
navigation,
|
||||
}: {
|
||||
|
||||
4
examples/chat-rn/src/jazz.tsx
Normal file
4
examples/chat-rn/src/jazz.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createJazzRNApp } from "jazz-react-native";
|
||||
|
||||
export const Jazz = createJazzRNApp();
|
||||
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
|
||||
@@ -1,104 +1,5 @@
|
||||
# chat-vue
|
||||
|
||||
## 0.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7491711]
|
||||
- jazz-tools@0.9.15
|
||||
- jazz-browser@0.9.15
|
||||
- jazz-vue@0.9.15
|
||||
|
||||
## 0.0.39
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3df93cc]
|
||||
- jazz-tools@0.9.14
|
||||
- jazz-browser@0.9.14
|
||||
- jazz-vue@0.9.14
|
||||
|
||||
## 0.0.38
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser@0.9.13
|
||||
- jazz-tools@0.9.13
|
||||
- jazz-vue@0.9.13
|
||||
|
||||
## 0.0.37
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser@0.9.12
|
||||
- jazz-tools@0.9.12
|
||||
- jazz-vue@0.9.12
|
||||
|
||||
## 0.0.36
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser@0.9.11
|
||||
- jazz-tools@0.9.11
|
||||
- jazz-vue@0.9.11
|
||||
|
||||
## 0.0.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5e83864]
|
||||
- jazz-tools@0.9.10
|
||||
- jazz-browser@0.9.10
|
||||
- jazz-vue@0.9.10
|
||||
|
||||
## 0.0.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8eb9247]
|
||||
- jazz-tools@0.9.9
|
||||
- jazz-browser@0.9.9
|
||||
- jazz-vue@0.9.9
|
||||
|
||||
## 0.0.33
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d1d773b]
|
||||
- jazz-tools@0.9.8
|
||||
- jazz-browser@0.9.8
|
||||
- jazz-vue@0.9.8
|
||||
|
||||
## 0.0.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1b71969]
|
||||
- Updated dependencies [5d98189]
|
||||
- jazz-browser@0.9.1
|
||||
- jazz-tools@0.9.1
|
||||
- jazz-vue@0.9.1
|
||||
|
||||
## 0.0.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9dd8d95]
|
||||
- Updated dependencies [8eda792]
|
||||
- jazz-vue@0.9.0
|
||||
- jazz-tools@0.9.0
|
||||
- jazz-browser@0.9.0
|
||||
|
||||
## 0.0.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dc62b95]
|
||||
- Updated dependencies [1de26f8]
|
||||
- jazz-tools@0.8.51
|
||||
- jazz-browser@0.8.51
|
||||
- jazz-vue@0.8.51
|
||||
|
||||
## 0.0.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,59 +1,29 @@
|
||||
# Chat example with Jazz and Vue
|
||||
|
||||
## Getting started
|
||||
## Installing & running the example locally
|
||||
|
||||
You can either
|
||||
1. Clone the jazz repository, and run the app within the monorepo.
|
||||
2. Or create a new Jazz project using this example as a template.
|
||||
(This requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
|
||||
|
||||
|
||||
### Using the example as a template
|
||||
|
||||
Create a new Jazz project, and use this example as a template.
|
||||
Start by downloading the [jazz repository](https://github.com/garden-co/jazz):
|
||||
```bash
|
||||
npm create jazz-app@latest --example chat-vue --project-name chat-vue
|
||||
```
|
||||
or
|
||||
```bash
|
||||
npx create-jazz-app@latest --example chat-vue --project-name chat-vue
|
||||
npx degit gardencmp/jazz jazz
|
||||
```
|
||||
|
||||
Go to the new project directory.
|
||||
Go to the todo-vue example directory:
|
||||
```bash
|
||||
cd chat-vue
|
||||
cd jazz/examples/chat-vue
|
||||
```
|
||||
|
||||
Run the dev server.
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Using the monorepo
|
||||
|
||||
This requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation).
|
||||
|
||||
Clone the jazz repository.
|
||||
```bash
|
||||
git clone https://github.com/garden-co/jazz.git
|
||||
```
|
||||
|
||||
Install and build dependencies.
|
||||
Install and build dependencies:
|
||||
```bash
|
||||
pnpm i && npx turbo build
|
||||
```
|
||||
|
||||
Go to the example directory.
|
||||
```bash
|
||||
cd jazz/examples/chat-vue/
|
||||
```
|
||||
|
||||
Start the dev server.
|
||||
Start the dev server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) with your browser to see the result.
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-vue",
|
||||
"version": "0.0.40",
|
||||
"version": "0.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAccount } from "jazz-vue";
|
||||
import AppContainer from "./components/AppContainer.vue";
|
||||
import TopBar from "./components/TopBar.vue";
|
||||
import { useAccount } from "./main";
|
||||
|
||||
const { me, logOut } = useAccount();
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { DemoAuthBasicUI, JazzProvider, useDemoAuth } from "jazz-vue";
|
||||
import { DemoAuthBasicUI, createJazzVueApp, useDemoAuth } from "jazz-vue";
|
||||
import { createApp, defineComponent, h } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./index.css";
|
||||
import router from "./router";
|
||||
|
||||
const Jazz = createJazzVueApp();
|
||||
export const { useAccount, useCoState } = Jazz;
|
||||
const { JazzProvider } = Jazz;
|
||||
|
||||
const RootComponent = defineComponent({
|
||||
name: "RootComponent",
|
||||
setup() {
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import type { ID } from "jazz-tools";
|
||||
import { useCoState } from "jazz-vue";
|
||||
import { type PropType, computed, defineComponent, ref } from "vue";
|
||||
import ChatBody from "../components/ChatBody.vue";
|
||||
import ChatBubble from "../components/ChatBubble.vue";
|
||||
import ChatInput from "../components/ChatInput.vue";
|
||||
import EmptyChatMessage from "../components/EmptyChatMessage.vue";
|
||||
import { useCoState } from "../main";
|
||||
import { Chat, Message } from "../schema";
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Group } from "jazz-tools";
|
||||
import { useAccount } from "jazz-vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAccount } from "../main";
|
||||
import { Chat } from "../schema";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,110 +1,5 @@
|
||||
# jazz-example-chat
|
||||
|
||||
## 0.0.136
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7491711]
|
||||
- jazz-tools@0.9.15
|
||||
- jazz-browser-media-images@0.9.15
|
||||
- jazz-react@0.9.15
|
||||
|
||||
## 0.0.135
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3df93cc]
|
||||
- jazz-tools@0.9.14
|
||||
- jazz-browser-media-images@0.9.14
|
||||
- jazz-react@0.9.14
|
||||
|
||||
## 0.0.134
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.13
|
||||
- jazz-tools@0.9.13
|
||||
- jazz-browser-media-images@0.9.13
|
||||
|
||||
## 0.0.133
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.12
|
||||
- jazz-tools@0.9.12
|
||||
- jazz-browser-media-images@0.9.12
|
||||
|
||||
## 0.0.132
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.11
|
||||
- jazz-tools@0.9.11
|
||||
- jazz-browser-media-images@0.9.11
|
||||
|
||||
## 0.0.131
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f76274c: Fix image handling in react-native
|
||||
- Updated dependencies [5e83864]
|
||||
- jazz-react@0.9.10
|
||||
- jazz-tools@0.9.10
|
||||
- jazz-browser-media-images@0.9.10
|
||||
|
||||
## 0.0.130
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8eb9247]
|
||||
- jazz-tools@0.9.9
|
||||
- jazz-browser-media-images@0.9.9
|
||||
- jazz-react@0.9.9
|
||||
|
||||
## 0.0.129
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d1d773b]
|
||||
- jazz-tools@0.9.8
|
||||
- jazz-react@0.9.8
|
||||
- jazz-browser-media-images@0.9.8
|
||||
|
||||
## 0.0.128
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.4
|
||||
|
||||
## 0.0.127
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1b71969]
|
||||
- jazz-react@0.9.1
|
||||
- jazz-tools@0.9.1
|
||||
- jazz-browser-media-images@0.9.1
|
||||
|
||||
## 0.0.126
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [956a4d1]
|
||||
- Updated dependencies [8eda792]
|
||||
- jazz-react@0.9.0
|
||||
- jazz-tools@0.9.0
|
||||
- jazz-browser-media-images@0.9.0
|
||||
|
||||
## 0.0.125
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dc62b95]
|
||||
- Updated dependencies [1de26f8]
|
||||
- jazz-tools@0.8.51
|
||||
- jazz-browser-media-images@0.8.51
|
||||
- jazz-react@0.8.51
|
||||
|
||||
## 0.0.124
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -2,60 +2,30 @@
|
||||
|
||||
Live version: [https://chat.jazz.tools](https://chat.jazz.tools)
|
||||
|
||||
## Getting started
|
||||
## Installing & running the example locally
|
||||
|
||||
You can either
|
||||
1. Clone the jazz repository, and run the app within the monorepo.
|
||||
2. Or create a new Jazz project using this example as a template.
|
||||
(This requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
|
||||
|
||||
|
||||
### Using the example as a template
|
||||
|
||||
Create a new Jazz project, and use this example as a template.
|
||||
Start by downloading the [jazz repository](https://github.com/garden-co/jazz):
|
||||
```bash
|
||||
npm create jazz-app@latest --example chat --project-name chat
|
||||
```
|
||||
or
|
||||
```bash
|
||||
npx create-jazz-app@latest --example chat --project-name chat
|
||||
npx degit gardencmp/jazz jazz
|
||||
```
|
||||
|
||||
Go to the new project directory.
|
||||
Go to the chat example directory:
|
||||
```bash
|
||||
cd chat
|
||||
cd jazz/examples/chat
|
||||
```
|
||||
|
||||
Run the dev server.
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Using the monorepo
|
||||
|
||||
This requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation).
|
||||
|
||||
Clone the jazz repository.
|
||||
```bash
|
||||
git clone https://github.com/garden-co/jazz.git
|
||||
```
|
||||
|
||||
Install and build dependencies.
|
||||
Install and build dependencies:
|
||||
```bash
|
||||
pnpm i && npx turbo build
|
||||
```
|
||||
|
||||
Go to the example directory.
|
||||
```bash
|
||||
cd jazz/examples/chat/
|
||||
```
|
||||
|
||||
Start the dev server.
|
||||
Start the dev server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) with your browser to see the result.
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
@@ -64,4 +34,4 @@ If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or
|
||||
|
||||
By default, the example app uses [Jazz Cloud](https://jazz.tools/cloud) (`wss://cloud.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx jazz-run sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?peer=ws://localhost:4200`), or by setting the `sync` parameter of the `<JazzProvider>` provider component in [./src/main.tsx](./src/main.tsx).
|
||||
You can also run a local sync server by running `npx jazz-run sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?peer=ws://localhost:4200`), or by setting the `sync` parameter of the `<Jazz.Provider>` provider component in [./src/main.tsx](./src/main.tsx).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-chat",
|
||||
"private": true,
|
||||
"version": "0.0.136",
|
||||
"version": "0.0.124",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user