Compare commits

..

2 Commits

Author SHA1 Message Date
Giordano Ricci
1d10111117 fix package name 2025-07-22 12:26:24 +01:00
Giordano Ricci
511ae983d5 wip: chat example using Quint 2025-07-22 12:24:02 +01:00
315 changed files with 4068 additions and 16683 deletions

View File

@@ -0,0 +1,5 @@
---
"quint-ui": patch
---
Initial Quint-UI release

View File

@@ -55,7 +55,6 @@ jobs:
"examples/inspector"
"examples/music-player"
"examples/organization"
"examples/server-worker-http"
"starters/react-passkey-auth"
"starters/svelte-passkey-auth"
"tests/jazz-svelte"

View File

@@ -14,7 +14,7 @@
"tests/jazz-svelte/src/**",
"examples/*svelte*/**",
"starters/*svelte*/**",
"examples/server-worker-inbox/src/routeTree.gen.ts",
"examples/jazz-paper-scissors/src/routeTree.gen.ts",
"homepage/homepage/**",
"**/package.json"
]

View File

@@ -1,80 +1,5 @@
# passkey-svelte
## 0.0.111
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [33ebbf0]
- jazz-tools@0.16.5
## 0.0.110
### Patch Changes
- Updated dependencies [16764f6]
- jazz-tools@0.16.4
## 0.0.109
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
## 0.0.108
### Patch Changes
- jazz-tools@0.16.2
## 0.0.107
### Patch Changes
- Updated dependencies [c62abef]
- jazz-tools@0.16.1
## 0.0.106
### Patch Changes
- 2bbb07b: Introduce a cleaner separation between Zod and CoValue schemas:
- Zod schemas and CoValue schemas are fully separated. Zod schemas can only be composed with other Zod schemas. CoValue schemas can be composed with either Zod or other CoValue schemas.
- `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas. Use `co.optional()` and `co.discriminatedUnion()` instead.
- Internal schema access is now simpler. You no longer need to use Zods `.def` to access internals. Use properties like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType` directly.
- CoValue schema types are now namespaced under `co.`. Non-namespaced exports have been removed
- CoMap schemas no longer incorrectly inherit from Zod. Previously, methods like `.extend()` and `.partial()` appeared available but could cause unexpected behavior. These methods are now disabled. In their place, `.optional()` has been added, and more Zod-like methods will be introduced in future releases.
- Upgraded Zod from `3.25.28` to `3.25.76`.
- Removed deprecated `withHelpers` method from CoValue schemas
- Removed deprecated `createCoValueObservable` function
- Updated dependencies [c09dcdf]
- Updated dependencies [2bbb07b]
- jazz-tools@0.16.0
## 0.0.105
### Patch Changes
- Updated dependencies [9633d01]
- Updated dependencies [4beafb7]
- jazz-tools@0.15.16
## 0.0.104
### Patch Changes
- Updated dependencies [3fe53a3]
- jazz-tools@0.15.15
## 0.0.103
### Patch Changes
- Updated dependencies [a584590]
- Updated dependencies [9acccb5]
- jazz-tools@0.15.14
## 0.0.102
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-svelte",
"version": "0.0.111",
"version": "0.0.102",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,8 +1,8 @@
import { co } from 'jazz-tools';
import { co, z } from 'jazz-tools';
export const Message = co.map({
text: co.plainText(),
image: co.optional(co.image())
image: z.optional(co.image())
});
export const Chat = co.list(Message);

View File

@@ -12,13 +12,13 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"clsx": "^2.0.0",
"hash-slash": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.274.0",
"quint-ui": "workspace:*",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "3.25.76"
"zod": "3.25.28"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
@@ -32,4 +32,4 @@
"typescript": "5.6.2",
"vite": "^6.3.5"
}
}
}

View File

@@ -4,6 +4,7 @@ import { useIframeHashRouter } from "hash-slash";
import { Group } from "jazz-tools";
import { JazzInspector } from "jazz-tools/inspector";
import { JazzReactProvider, useAccount } from "jazz-tools/react";
import { Button } from "quint-ui";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ChatScreen } from "./chatScreen.tsx";
@@ -39,7 +40,11 @@ export function App() {
}}
placeholder="Set username"
/>
{!inIframe && <button onClick={logOut}>Log out</button>}
{!inIframe && (
<Button intent="danger" onClick={logOut} variant="outline">
Log out
</Button>
)}
</TopBar>
{router.route({
"/": () => createChat() as never,

View File

@@ -1,4 +1,6 @@
@import "tailwindcss";
@import "quint-ui/styles.css";
/* We need to tell tailwind to detect classes from quint components */
@source "../node_modules/quint-ui";
/* Custom stone color palette */
@theme {

View File

@@ -1,7 +1,7 @@
import clsx from "clsx";
import { CoPlainText, ImageDefinition } from "jazz-tools";
import type { CoPlainText, ImageDefinition } from "jazz-tools";
import { ProgressiveImg } from "jazz-tools/react";
import { ImageIcon } from "lucide-react";
import { cn } from "quint-ui";
import { useId, useRef } from "react";
export function AppContainer(props: { children: React.ReactNode }) {
@@ -57,7 +57,7 @@ export function BubbleBody(props: {
}) {
return (
<div
className={clsx(
className={cn(
"line-clamp-10 text-ellipsis whitespace-pre-wrap",
"rounded-2xl overflow-hidden max-w-[calc(100%-5rem)] shadow-sm p-1",
props.fromMe
@@ -75,9 +75,7 @@ export function BubbleText(props: {
className?: string;
}) {
return (
<p className={clsx("px-2 leading-relaxed", props.className)}>
{props.text}
</p>
<p className={cn("px-2 leading-relaxed", props.className)}>{props.text}</p>
);
}

View File

@@ -1,4 +1,5 @@
import { useIframeHashRouter } from "hash-slash";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { useState } from "react";
import { Errors } from "./Errors.tsx";
@@ -8,7 +9,7 @@ import {
BubbleTeaOrder,
DraftBubbleTeaOrder,
JazzAccount,
validateDraftOrder,
ListOfBubbleTeaAddOns,
} from "./schema.ts";
export function CreateOrder() {
@@ -20,19 +21,20 @@ export function CreateOrder() {
if (!me?.root) return;
const onSave = (draft: DraftBubbleTeaOrder) => {
const validation = validateDraftOrder(draft);
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
// validate if the draft is a valid order
const validation = DraftBubbleTeaOrder.validate(draft);
setErrors(validation.errors);
if (validation.errors.length > 0) {
return;
}
// turn the draft into a real order
me.root.orders.push(draft as BubbleTeaOrder);
me.root.orders.push(draft as Loaded<typeof BubbleTeaOrder>);
// reset the draft
me.root.draft = DraftBubbleTeaOrder.create({
addOns: [],
addOns: ListOfBubbleTeaAddOns.create([]),
});
router.navigate("/");
@@ -58,7 +60,7 @@ function CreateOrderForm({
onSave,
}: {
id: string;
onSave: (draft: DraftBubbleTeaOrder) => void;
onSave: (draft: Loaded<typeof DraftBubbleTeaOrder>) => void;
}) {
const draft = useCoState(DraftBubbleTeaOrder, id, {
resolve: { addOns: true, instructions: true },

View File

@@ -1,11 +1,11 @@
import { useAccount } from "jazz-tools/react";
import { JazzAccount, hasChanges } from "./schema";
import { DraftBubbleTeaOrder, JazzAccount } from "./schema";
export function DraftIndicator() {
const { me } = useAccount(JazzAccount, {
resolve: { root: { draft: true } },
});
if (hasChanges(me?.root.draft)) {
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
return (
<div className="absolute -top-1 -right-1 bg-blue-500 border-2 border-white w-3 h-3 rounded-full dark:border-stone-925">
<span className="sr-only">You have a draft</span>

View File

@@ -1,4 +1,4 @@
import { CoPlainText } from "jazz-tools";
import { CoPlainText, Loaded } from "jazz-tools";
import {
BubbleTeaAddOnTypes,
BubbleTeaBaseTeaTypes,
@@ -10,7 +10,7 @@ export function OrderForm({
order,
onSave,
}: {
order: BubbleTeaOrder | DraftBubbleTeaOrder;
order: Loaded<typeof BubbleTeaOrder> | Loaded<typeof DraftBubbleTeaOrder>;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
// Handles updates to the instructions field of the order.

View File

@@ -1,9 +1,10 @@
import { Loaded } from "jazz-tools";
import { BubbleTeaOrder } from "./schema.ts";
export function OrderThumbnail({
order,
}: {
order: BubbleTeaOrder;
order: Loaded<typeof BubbleTeaOrder>;
}) {
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
const date = deliveryDate.toLocaleDateString();

View File

@@ -1,4 +1,4 @@
import { co, z } from "jazz-tools";
import { Loaded, co, z } from "jazz-tools";
export const BubbleTeaAddOnTypes = [
"Pearl",
@@ -15,14 +15,13 @@ export const BubbleTeaBaseTeaTypes = [
"Thai",
] as const;
export const ListOfBubbleTeaAddOns = co.list(
z.literal([...BubbleTeaAddOnTypes]),
);
export type ListOfBubbleTeaAddOns = co.loaded<typeof ListOfBubbleTeaAddOns>;
function hasAddOnsChanges(list?: ListOfBubbleTeaAddOns | null) {
return list && Object.entries(list._raw.insertions).length > 0;
}
export const ListOfBubbleTeaAddOns = co
.list(z.literal([...BubbleTeaAddOnTypes]))
.withHelpers((Self) => ({
hasChanges(list?: Loaded<typeof Self> | null) {
return list && Object.entries(list._raw.insertions).length > 0;
},
}));
export const BubbleTeaOrder = co.map({
baseTea: z.literal([...BubbleTeaBaseTeaTypes]),
@@ -31,30 +30,37 @@ export const BubbleTeaOrder = co.map({
withMilk: z.boolean(),
instructions: co.optional(co.plainText()),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const DraftBubbleTeaOrder = co
.map({
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
addOns: co.optional(ListOfBubbleTeaAddOns),
deliveryDate: z.optional(z.date()),
withMilk: z.optional(z.boolean()),
instructions: co.optional(co.plainText()),
})
.withHelpers((Self) => ({
hasChanges(order: Loaded<typeof Self> | undefined) {
return (
!!order &&
(Object.keys(order._edits).length > 1 ||
ListOfBubbleTeaAddOns.hasChanges(order.addOns))
);
},
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
const errors: string[] = [];
validate(order: Loaded<typeof Self>) {
const errors: string[] = [];
if (!order.baseTea) {
errors.push("Please select your preferred base tea.");
}
if (!order.deliveryDate) {
errors.push("Plese select a delivery date.");
}
if (!order.baseTea) {
errors.push("Please select your preferred base tea.");
}
if (!order.deliveryDate) {
errors.push("Plese select a delivery date.");
}
return { errors };
}
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
return (
!!order &&
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
);
}
return { errors };
},
}));
/** The root is an app-specific per-user private `CoMap`
* where you can store top-level objects for that user */
@@ -70,9 +76,15 @@ export const JazzAccount = co
})
.withMigration((account) => {
if (!account.root) {
account.root = AccountRoot.create(
{ draft: { addOns: [], instructions: "" }, orders: [] },
const orders = co.list(BubbleTeaOrder).create([], account);
const draft = DraftBubbleTeaOrder.create(
{
addOns: ListOfBubbleTeaAddOns.create([], account),
instructions: co.plainText().create("", account),
},
account,
);
account.root = AccountRoot.create({ draft, orders }, account);
}
});

View File

@@ -11,9 +11,9 @@ export const Issue = co.map({
status: z.enum(["open", "closed"]),
labels: co.list(z.string()),
reactions: ReactionsList,
file: co.optional(co.fileStream()),
image: co.optional(co.image()),
lead: co.optional(co.account()),
file: z.optional(co.fileStream()),
image: z.optional(co.image()),
lead: z.optional(co.account()),
});
export const Project = co.map({

View File

@@ -1 +0,0 @@
export const apiKey = "jazz-nextjs@garden.co";

View File

@@ -1,13 +1,12 @@
import { JazzInspector } from "jazz-tools/inspector";
import { JazzReactProvider } from "jazz-tools/react";
import { apiKey } from "./apiKey";
export function Jazz({ children }: { children: React.ReactNode }) {
return (
<JazzReactProvider
enableSSR
sync={{
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
peer: `wss://cloud.jazz.tools/`,
}}
>
{children}

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -15,7 +15,7 @@
"jazz-tools": "workspace:*",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "3.25.76"
"zod": "3.25.28"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -31,4 +31,4 @@
"vite": "^6.3.5",
"vitest": "3.1.1"
}
}
}

View File

@@ -38,7 +38,6 @@
"postcss": "^8.4.27",
"tailwindcss": "^4.1.10",
"typescript": "5.6.2",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.2"
"vite": "^6.3.5"
}
}

View File

@@ -84,14 +84,15 @@ export const MusicaAccount = co
* You can use it to set up the account root and any other initial CoValues you need.
*/
if (account.root === undefined) {
const tracks = co.list(MusicTrack).create([]);
const rootPlaylist = Playlist.create({
tracks: [],
tracks,
title: "",
});
account.root = MusicaAccountRoot.create({
rootPlaylist,
playlists: [],
playlists: co.list(Playlist).create([]),
activeTrack: undefined,
activePlaylist: rootPlaylist,
exampleDataLoaded: false,

View File

@@ -11,7 +11,6 @@ import { uploadMusicTracks } from "./4_actions";
import { MediaPlayer } from "./5_useMediaPlayer";
import { FileUploadButton } from "./components/FileUploadButton";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { PlayerControls } from "./components/PlayerControls";
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
import { SidePanel } from "./components/SidePanel";
import { Button } from "./components/ui/button";
@@ -43,11 +42,7 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const playlistId = params.playlistId ?? me?.root._refs.rootPlaylist.id;
const playlist = useCoState(Playlist, playlistId, {
resolve: {
tracks: {
$each: true,
},
},
resolve: { tracks: true },
});
const isRootPlaylist = !params.playlistId;
@@ -71,8 +66,8 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
return (
<SidebarInset className="flex flex-col h-screen text-gray-800 bg-blue-50">
<div className="flex flex-1 overflow-hidden">
<SidePanel />
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden relative">
<SidePanel mediaPlayer={mediaPlayer} />
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden">
<SidebarTrigger />
<div className="flex items-center justify-between mb-6">
{isRootPlaylist ? (
@@ -111,12 +106,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
onClick={() => {
mediaPlayer.setActiveTrack(track, playlist);
}}
showAddToPlaylist={isRootPlaylist}
/>
),
)}
</ul>
</main>
<PlayerControls mediaPlayer={mediaPlayer} />
</div>
</SidebarInset>
);

View File

@@ -1,6 +1,11 @@
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
import { FileStream, Group } from "jazz-tools";
import { MusicTrack, MusicaAccount, Playlist } from "./1_schema";
import { FileStream, Group, co } from "jazz-tools";
import {
MusicTrack,
MusicTrackWaveform,
MusicaAccount,
Playlist,
} from "./1_schema";
/**
* Walkthrough: Actions
@@ -46,7 +51,7 @@ export async function uploadMusicTracks(
{
file: fileStream,
duration: data.duration,
waveform: { data: data.waveform },
waveform: MusicTrackWaveform.create({ data: data.waveform }, group),
title: file.name,
isExampleTrack,
},
@@ -68,10 +73,18 @@ export async function createNewPlaylist() {
},
});
const playlist = Playlist.create({
title: "New Playlist",
tracks: [],
});
// Since playlists are meant to be shared we associate them
// to a group which will contain the keys required to get
// access to the "owned" values
const playlistGroup = Group.create();
const playlist = Playlist.create(
{
title: "New Playlist",
tracks: co.list(MusicTrack).create([], playlistGroup),
},
playlistGroup,
);
// Again, we associate the new playlist to the
// user by pushing it into the playlists CoList
@@ -116,7 +129,7 @@ export async function removeTrackFromPlaylist(
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
const trackGroup = track._owner;
trackGroup.removeMember(playlist._owner);
await trackGroup.removeMember(playlist._owner);
const index =
playlist.tracks?.findIndex(

View File

@@ -1,59 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ConfirmDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
variant?: "default" | "destructive";
}
export function ConfirmDialog({
isOpen,
onOpenChange,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
onConfirm,
variant = "destructive",
}: ConfirmDialogProps) {
function handleConfirm() {
onConfirm();
onOpenChange(false);
}
function handleCancel() {
onOpenChange(false);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{cancelText}
</Button>
<Button variant={variant} onClick={handleConfirm}>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -10,34 +10,27 @@ import { cn } from "@/lib/utils";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { MoreHorizontal } from "lucide-react";
import { Fragment, useCallback, useState } from "react";
import { EditTrackDialog } from "./RenameTrackDialog";
import { Fragment } from "react/jsx-runtime";
import { MusicTrackTitleInput } from "./MusicTrackTitleInput";
import { Button } from "./ui/button";
function isPartOfThePlaylist(
trackId: string,
playlist: Loaded<typeof Playlist, { tracks: true }>,
) {
return Array.from(playlist.tracks._refs).some((t) => t.id === trackId);
}
export function MusicTrackRow({
trackId,
isLoading,
isPlaying,
onClick,
showAddToPlaylist,
}: {
trackId: string;
isLoading: boolean;
isPlaying: boolean;
onClick: (track: Loaded<typeof MusicTrack>) => void;
showAddToPlaylist: boolean;
}) {
const track = useCoState(MusicTrack, trackId);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: { tracks: true } } } },
resolve: { root: { playlists: { $each: true } } },
});
const playlists = me?.root.playlists ?? [];
@@ -67,18 +60,10 @@ export function MusicTrackRow({
}
}
function handleEdit() {
setIsEditDialogOpen(true);
}
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDropdownOpen(true);
}, []);
return (
<li
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
onClick={handleTrackClick}
>
<button
className={cn(
@@ -96,57 +81,50 @@ export function MusicTrackRow({
"▶️"
)}
</button>
<button
onContextMenu={handleContextMenu}
onClick={handleTrackClick}
className="w-full flex items-center overflow-hidden text-ellipsis whitespace-nowrap"
>
{track?.title}
</button>
<MusicTrackTitleInput trackId={trackId} />
<div onClick={(evt) => evt.stopPropagation()}>
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
{playlists.map((playlist, index) => (
<Fragment key={index}>
{isPartOfThePlaylist(trackId, playlist) ? (
<DropdownMenuItem
key={`remove-${index}`}
onSelect={() => handleRemoveFromPlaylist(playlist)}
>
Remove from {playlist.title}
</DropdownMenuItem>
) : (
{showAddToPlaylist && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
key={`delete`}
onSelect={async () => {
if (!track) return;
deleteTrack();
}}
>
Delete
</DropdownMenuItem>
{playlists.map((playlist, index) => (
<Fragment key={index}>
<DropdownMenuItem
key={`add-${index}`}
onSelect={() => handleAddToPlaylist(playlist)}
>
Add to {playlist.title}
</DropdownMenuItem>
)}
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem
key={`remove-${index}`}
onSelect={() => handleRemoveFromPlaylist(playlist)}
>
Remove from {playlist.title}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{track && isEditDialogOpen && (
<EditTrackDialog
track={track}
isOpen={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onDelete={deleteTrack}
/>
)}
</li>
);
}

View File

@@ -24,25 +24,25 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const activeTrackTitle = activeTrack.title;
return (
<footer className="flex items-center justify-between p-2 sm:p-4 gap-2 sm:gap-4 bg-white border-t border-gray-200 absolute bottom-0 left-0 right-0 w-full z-50">
<div className="flex justify-center items-center space-x-1 sm:space-x-2 flex-shrink-0">
<div className="flex items-center space-x-2 sm:space-x-4">
<footer className="flex items-center justify-between p-4 gap-4 bg-white border-t border-gray-200 fixed bottom-0 left-0 right-0 w-full">
<div className="flex justify-center items-center space-x-2">
<div className="flex items-center space-x-4">
<button
onClick={mediaPlayer.playPrevTrack}
className="text-blue-600 hover:text-blue-800"
aria-label="Previous track"
>
<SkipBack size={16} className="sm:w-5 sm:h-5" />
<SkipBack size={20} />
</button>
<button
onClick={playState.toggle}
className="w-8 h-8 sm:w-[42px] sm:h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
className="w-[42px] h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
aria-label={isPlaying ? "Pause active track" : "Play active track"}
>
{isPlaying ? (
<Pause size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
<Pause size={24} fill="currentColor" />
) : (
<Play size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
<Play size={24} fill="currentColor" />
)}
</button>
<button
@@ -50,22 +50,16 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
className="text-blue-600 hover:text-blue-800"
aria-label="Next track"
>
<SkipForward size={16} className="sm:w-5 sm:h-5" />
<SkipForward size={20} />
</button>
</div>
</div>
<div className="md:hidden sm:hidden lg:flex flex-1 justify-center items-center min-w-0 px-2">
<Waveform
track={activeTrack}
height={30}
className="h-5 sm:h-6 md:h-8 lg:h-10"
/>
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
<Waveform track={activeTrack} height={30} />
</div>
<div className="flex flex-col items-end gap-1 text-right min-w-fit flex-shrink-0">
<h4 className="font-medium text-blue-800 text-sm sm:text-base truncate max-w-32 sm:max-w-80">
{activeTrackTitle}
</h4>
<p className="text-xs sm:text-sm text-gray-600 truncate max-w-32 sm:max-w-80">
<div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]">
<h4 className="font-medium text-blue-800">{activeTrackTitle}</h4>
<p className="text-sm text-gray-600">
{activePlaylist?.title || "All tracks"}
</p>
</div>

View File

@@ -1,108 +0,0 @@
import { MusicTrack } from "@/1_schema";
import { updateMusicTrackTitle } from "@/4_actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Loaded } from "jazz-tools";
import { useState } from "react";
import { ConfirmDialog } from "./ConfirmDialog";
interface EditTrackDialogProps {
track: Loaded<typeof MusicTrack>;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onDelete: () => void;
}
export function EditTrackDialog({
track,
isOpen,
onOpenChange,
onDelete,
}: EditTrackDialogProps) {
const [newTitle, setNewTitle] = useState(track.title);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
function handleSave() {
if (track && newTitle.trim()) {
updateMusicTrackTitle(track, newTitle.trim());
onOpenChange(false);
}
}
function handleCancel() {
setNewTitle(track?.title || "");
onOpenChange(false);
}
function handleDeleteClick() {
setIsDeleteConfirmOpen(true);
}
function handleDeleteConfirm() {
onDelete();
onOpenChange(false);
}
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Enter") {
handleSave();
} else if (event.key === "Escape") {
handleCancel();
}
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Track</DialogTitle>
<DialogDescription>Edit "{track?.title}".</DialogDescription>
</DialogHeader>
<form className="py-4" onSubmit={handleSave}>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter track name..."
autoFocus
/>
</form>
<DialogFooter className="flex justify-between">
<Button
variant="destructive"
onClick={handleDeleteClick}
className="mr-auto"
>
Delete Track
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!newTitle.trim()}>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
<ConfirmDialog
isOpen={isDeleteConfirmOpen}
onOpenChange={setIsDeleteConfirmOpen}
title="Delete Track"
description={`Are you sure you want to delete "${track.title}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDeleteConfirm}
variant="destructive"
/>
</Dialog>
);
}

View File

@@ -1,8 +1,10 @@
import { MusicaAccount } from "@/1_schema";
import { MusicTrack, MusicaAccount } from "@/1_schema";
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
import { MediaPlayer } from "@/5_useMediaPlayer";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
@@ -12,18 +14,22 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useAccount } from "jazz-tools/react";
import { Home, Music, Plus, Trash2 } from "lucide-react";
import { usePlayState } from "@/lib/audio/usePlayState";
import { useAccount, useCoState } from "jazz-tools/react";
import { Home, Music, Pause, Play, Plus, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { AuthButton } from "./AuthButton";
export function SidePanel() {
export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const { playlistId } = useParams();
const navigate = useNavigate();
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
});
const playState = usePlayState();
const isPlaying = playState.value === "play";
function handleAllTracksClick() {
navigate(`/`);
}
@@ -44,6 +50,12 @@ export function SidePanel() {
navigate(`/playlist/${playlist.id}`);
}
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
resolve: { waveform: true },
});
const activeTrackTitle = activeTrack?.title;
return (
<Sidebar>
<SidebarHeader>
@@ -125,6 +137,29 @@ export function SidePanel() {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{activeTrack && (
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem className="flex justify-end">
<SidebarMenuButton
onClick={playState.toggle}
aria-label={
isPlaying ? "Pause active track" : "Play active track"
}
>
<div className="w-[28px] h-[28px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700">
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" />
)}
</div>
<span>{activeTrackTitle}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
)}
</Sidebar>
);
}

View File

@@ -7,7 +7,6 @@ import { useCoState } from "jazz-tools/react";
export function Waveform(props: {
track: Loaded<typeof MusicTrack>;
height: number;
className?: string;
}) {
const { track, height } = props;
const waveformData = useCoState(
@@ -37,7 +36,7 @@ export function Waveform(props: {
return (
<div
className={cn("flex justify-center items-end w-full", props.className)}
className="flex justify-center items-end w-full"
style={{
height,
gap: 1,

View File

@@ -2,12 +2,6 @@
@custom-variant dark (&:is(.dark *));
html {
overflow: hidden;
max-width: 1200px;
position: relative;
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(20 14.3% 4.1%);

View File

@@ -55,20 +55,10 @@ export class HomePage {
async editTrackTitle(trackTitle: string, newTitle: string) {
await this.page
.getByRole("button", {
name: `Open ${trackTitle} menu`,
.getByRole("textbox", {
name: `Edit track title: ${trackTitle}`,
})
.click();
await this.page
.getByRole("menuitem", {
name: `Edit`,
})
.click();
await this.page.getByPlaceholder("Enter track name...").fill(newTitle);
await this.page.getByRole("button", { name: "Save" }).click();
.fill(newTitle);
}
async createPlaylist() {

View File

@@ -1,21 +1,10 @@
import path from "path";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
strategies: "generateSW",
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
maximumFileSizeToCacheInBytes: 1024 * 1024 * 5,
},
}),
],
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

View File

@@ -7,7 +7,6 @@ import {
JazzAccount,
Organization,
Project,
validateDraftOrganization,
} from "../schema.ts";
import { Errors } from "./Errors.tsx";
import { OrganizationForm } from "./OrganizationForm.tsx";
@@ -22,7 +21,8 @@ export function CreateOrganization() {
if (!me?.root?.organizations) return;
const onSave = (draft: Loaded<typeof DraftOrganization>) => {
const validation = validateDraftOrganization(draft);
// validate if the draft is a valid organization
const validation = DraftOrganization.validate(draft);
setErrors(validation.errors);
if (validation.errors.length > 0) {
return;

View File

@@ -10,24 +10,24 @@ export const Organization = co.map({
projects: co.list(Project),
});
export const DraftOrganization = co.map({
name: z.optional(z.string()),
projects: co.list(Project),
});
export const DraftOrganization = co
.map({
name: z.optional(z.string()),
projects: co.list(Project),
})
.withHelpers((Self) => ({
validate(org: Loaded<typeof Self>) {
const errors: string[] = [];
export function validateDraftOrganization(
org: Loaded<typeof DraftOrganization>,
) {
const errors: string[] = [];
if (!org.name) {
errors.push("Please enter a name.");
}
if (!org.name) {
errors.push("Please enter a name.");
}
return {
errors,
};
}
return {
errors,
};
},
}));
export const JazzAccountRoot = co.map({
organizations: co.list(Organization),

View File

@@ -0,0 +1,3 @@
{
"ignoreCommand": "echo true"
}

View File

@@ -1,47 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# env files
.env
.env.*
!.env.example
!.env.test

View File

@@ -1,105 +0,0 @@
# Rock Paper Scissors with Jazz HTTP API
This example demonstrates how to use the **Jazz HTTP API** with **Next.js** to implement updates in a trusted environment. The application implements a multiplayer Rock Paper Scissors game where the server validates game actions and reveals player intentions only after both players have made their moves.
## 🎯 Key Concepts Demonstrated
### Trusted Environment Updates
- **Server-side validation**: All game actions are validated by the server before being applied
- **Secure reveal mechanism**: Player choices are hidden until both players have acted, built with Jazz group permissions
## 🚀 Getting Started
### Prerequisites
- Node.js 20+
- pnpm (recommended) or npm
### Installation
1. **Install dependencies**:
```bash
pnpm install
```
2. **Generate environment variables**:
```bash
pnpm generate-env
```
3. **Start the development server**:
```bash
pnpm dev
```
4. **Open your browser** to [http://localhost:3000](http://localhost:3000)
### Environment Setup
The `generate-env.ts` script creates the necessary environment variables for Jazz:
- `NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT`: Server worker account ID
- `JAZZ_WORKER_SECRET`: Server worker secret key
## 🔧 Key Implementation Details
### Server API Definition
```typescript
const playRequest = experimental_defineRequest({
url: "/api/play",
workerId,
request: {
schema: {
game: Game,
selection: z.literal(["rock", "paper", "scissors"]),
},
resolve: {
game: {
player1: { account: true, playSelection: { group: true } },
player2: { account: true, playSelection: { group: true } },
},
},
},
response: {
schema: { game: Game },
resolve: { game: true },
},
});
```
### Secure Move Handling
```typescript
// Create restricted group for the move
const group = Group.create({ owner: jazzServerAccount.worker });
group.addMember(madeBy, "reader");
// Store move with restricted access
const playSelection = PlaySelection.create(
{ value: selection, group },
group,
);
// Reveal moves only after both players have acted
if (player1PlaySelection && player2PlaySelection) {
player1PlaySelection.group.addMember(game.player2.account, "reader");
player2PlaySelection.group.addMember(game.player1.account, "reader");
}
```
## 🎯 Use Cases
This pattern is ideal for applications requiring:
- **Fair play guarantees**: Preventing cheating in games
- **Simultaneous reveals**: Auctions, voting, or sealed-bid systems
- **Trusted computation**: Server-side validation of complex business logic
- **Real-time collaboration**: Multi-user applications with strict rules
## 📚 Learn More
- [Jazz Documentation](https://jazz.tools/docs)
- [Next.js App Router](https://nextjs.org/docs/app)
- [Groups & Permissions](https://jazz.tools/docs/react/groups/intro)
- [HTTP API with experimental_defineRequest](https://jazz.tools/docs/react/server-side/http-requests)
## 🤝 Contributing
This example is part of the Jazz framework. Feel free to submit issues and enhancement requests!

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,26 +0,0 @@
import * as fs from "fs";
import { createWorkerAccount } from "jazz-run/createWorkerAccount";
if (fs.existsSync(".env")) {
process.exit(0);
}
async function main() {
const account = await createWorkerAccount({
name: "jazz-paper-scissors-worker",
peer: "wss://cloud.jazz.tools/?key=jazz-paper-scissors@garden.co",
});
fs.writeFileSync(
".env",
`
JAZZ_WORKER_ACCOUNT=${account.accountID}
NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT=${account.accountID}
JAZZ_WORKER_SECRET=${account.agentSecret}
`,
);
process.exit(0);
}
main();

View File

@@ -1,5 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;

View File

@@ -1,40 +0,0 @@
{
"name": "server-worker-http",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"generate-env": "tsx generate-env.ts",
"build": "pnpm generate-env && next build",
"start": "next start",
"lint": "next lint",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/postcss": "^4.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jazz-tools": "workspace:*",
"lucide-react": "^0.525.0",
"next": "15.3.2",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"jazz-run": "workspace:*",
"tsx": "^4.19.3",
"tw-animate-css": "^1.2.5",
"typescript": "^5"
}
}

View File

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

View File

@@ -1,2 +0,0 @@
const config = { plugins: { "@tailwindcss/postcss": {} } };
export default config;

View File

@@ -1 +0,0 @@
export const apiKey = "server-side-validation@garden.co";

View File

@@ -1,23 +0,0 @@
import { jazzServerAccount } from "@/jazzServerAccount";
import { WaitingRoom } from "@/schema";
import { serverApi } from "@/serverApi";
import { Group } from "jazz-tools";
export async function POST(request: Request) {
const response = await serverApi.createGame.handle(
request,
jazzServerAccount.worker,
async (_, madeBy) => {
const waitingRoom = WaitingRoom.create(
{ creator: madeBy },
Group.create(jazzServerAccount.worker).makePublic(),
);
return {
waitingRoom,
};
},
);
return response;
}

View File

@@ -1,49 +0,0 @@
import { jazzServerAccount } from "@/jazzServerAccount";
import { Game, createGameState } from "@/schema";
import { serverApi } from "@/serverApi";
import { Account, Group, JazzRequestError } from "jazz-tools";
export async function POST(request: Request) {
return serverApi.joinGame.handle(
request,
jazzServerAccount.worker,
async ({ waitingRoom }, madeBy) => {
if (madeBy.id === waitingRoom.creator.id) {
throw new JazzRequestError("You can't join your own waiting room", 400);
}
waitingRoom.game = createGame({
account1: waitingRoom.creator,
account2: madeBy,
worker: jazzServerAccount.worker,
});
return {
waitingRoom,
};
},
);
}
interface CreateGameParams {
account1: Account;
account2: Account;
worker: Account;
}
function createGame({ account1, account2, worker }: CreateGameParams) {
const gameGroup = Group.create({ owner: worker });
gameGroup.addMember(account1, "reader");
gameGroup.addMember(account2, "reader");
const game = Game.create(
{
...createGameState({ account1, account2, worker }),
player1Score: 0,
player2Score: 0,
},
gameGroup,
);
return game;
}

View File

@@ -1,35 +0,0 @@
import { jazzServerAccount } from "@/jazzServerAccount";
import { createGameState } from "@/schema";
import { serverApi } from "@/serverApi";
import { JazzRequestError } from "jazz-tools";
export async function POST(request: Request) {
const response = await serverApi.newGame.handle(
request,
jazzServerAccount.worker,
async ({ game }, madeBy) => {
const isPlayer1 = game.player1.account.id === madeBy.id;
const isPlayer2 = game.player2.account.id === madeBy.id;
if (!isPlayer1 && !isPlayer2) {
throw new JazzRequestError("You are not a player in this game", 400);
}
if (game.outcome) {
game.applyDiff(
createGameState({
account1: game.player1.account,
account2: game.player2.account,
worker: jazzServerAccount.worker,
}),
);
}
return {
game,
};
},
);
return response;
}

View File

@@ -1,89 +0,0 @@
import { jazzServerAccount } from "@/jazzServerAccount";
import { PlaySelection } from "@/schema";
import { serverApi } from "@/serverApi";
import { Group, JazzRequestError } from "jazz-tools";
export async function POST(request: Request) {
const response = await serverApi.play.handle(
request,
jazzServerAccount.worker,
async ({ game, selection }, madeBy) => {
const isPlayer1 = game.player1.account.id === madeBy.id;
const isPlayer2 = game.player2.account.id === madeBy.id;
if (!isPlayer1 && !isPlayer2) {
throw new JazzRequestError("You are not a player in this game", 400);
}
const group = Group.create({ owner: jazzServerAccount.worker });
group.addMember(madeBy, "reader");
if (isPlayer1 && game.player1.playSelection) {
throw new JazzRequestError("You have already played", 400);
} else if (isPlayer2 && game.player2.playSelection) {
throw new JazzRequestError("You have already played", 400);
}
const playSelection = PlaySelection.create(
{ value: selection, group },
group,
);
if (isPlayer1) {
game.player1.playSelection = playSelection;
} else {
game.player2.playSelection = playSelection;
}
if (game.player1.playSelection && game.player2.playSelection) {
game.outcome = determineOutcome(
game.player1.playSelection.value,
game.player2.playSelection.value,
);
// Reveal the play selections to the other player
game.player1.playSelection.group.addMember(
game.player2.account,
"reader",
);
game.player2.playSelection.group.addMember(
game.player1.account,
"reader",
);
if (game.outcome === "player1") {
game.player1Score++;
} else if (game.outcome === "player2") {
game.player2Score++;
}
}
return {
game,
result: "success",
};
},
);
return response;
}
/**
* Given a player selections, returns the winner of the current game.
*/
function determineOutcome(
player1Choice: "rock" | "paper" | "scissors",
player2Choice: "rock" | "paper" | "scissors",
) {
if (player1Choice === player2Choice) {
return "draw";
} else if (
(player1Choice === "rock" && player2Choice === "scissors") ||
(player1Choice === "paper" && player2Choice === "rock") ||
(player1Choice === "scissors" && player2Choice === "paper")
) {
return "player1";
} else {
return "player2";
}
}

View File

@@ -1,388 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Game, PlaySelection, PlayerState } from "@/schema";
import { serverApi } from "@/serverApi";
import { Group, isJazzRequestError } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import {
CheckCircle,
Clock,
Gamepad2,
Minus,
Sparkles,
Trophy,
Users,
XCircle,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
const playIcon = (
selection: "rock" | "paper" | "scissors" | undefined,
size: "sm" | "lg" = "sm",
) => {
const emojiSize = size === "lg" ? "text-4xl" : "text-2xl";
switch (selection) {
case "rock":
return (
<div className="flex items-center gap-2">
<span className={`${emojiSize} `} style={{ animationDelay: "0ms" }}>
🪨
</span>
<span className="font-semibold">Rock</span>
</div>
);
case "paper":
return (
<div className="flex items-center gap-2">
<span className={`${emojiSize} `} style={{ animationDelay: "150ms" }}>
📄
</span>
<span className="font-semibold">Paper</span>
</div>
);
case "scissors":
return (
<div className="flex items-center gap-2">
<span className={`${emojiSize} `} style={{ animationDelay: "300ms" }}>
</span>
<span className="font-semibold">Scissors</span>
</div>
);
default:
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className={size === "lg" ? "w-8 h-8" : "w-6 h-6"} />
<span>Waiting for selection</span>
</div>
);
}
};
const getOutcomeIcon = (outcome: string | undefined, player: string) => {
if (outcome === player) {
return <Trophy className="w-6 h-6 text-yellow-500" />;
} else if (outcome === "draw") {
return <Minus className="w-6 h-6 text-blue-500" />;
} else {
return <XCircle className="w-6 h-6 text-red-500" />;
}
};
export default function RouteComponent() {
const params = useParams<{ id: string }>();
const game = useCoState(Game, params.id, {
resolve: {
player1State: {
$onError: null,
},
player2State: {
$onError: null,
},
player1: {
account: true,
playSelection: {
$onError: null,
},
},
player2: {
account: true,
playSelection: {
$onError: null,
},
},
},
});
const isPlayer1 = game?.player1?.account?.isMe;
const player = isPlayer1 ? "player1" : "player2";
if (!game) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<div className="animate-pulse">
<Gamepad2 className="w-12 h-12 text-muted-foreground" />
</div>
</div>
);
}
const gameComplete = game.outcome !== undefined;
const opponent = isPlayer1 ? "player2" : "player1";
const currentPlayer = game[player];
const opponentPlayer = game[opponent];
const currentPlayerState = game[isPlayer1 ? "player1State" : "player2State"];
const opponentSelection = opponentPlayer?.playSelection;
const opponentHasSelected = Boolean(opponentPlayer._refs.playSelection);
const handleSelection = (selection: "rock" | "paper" | "scissors") => {
if (!currentPlayerState) return;
if (currentPlayerState.submitted) return;
currentPlayerState.currentSelection = selection;
};
const handleSubmit = async (
playSelection: "rock" | "paper" | "scissors" | undefined,
) => {
if (!playSelection) return;
if (!currentPlayerState) return;
currentPlayerState.submitted = true;
try {
await serverApi.play.send({
game,
selection: playSelection,
});
} catch (error) {
currentPlayerState.submitted = false;
if (isJazzRequestError(error)) {
toast.error(error.message);
} else {
console.error(error);
toast.error("An unexpected error occurred");
}
}
};
const handleNewGame = async () => {
if (!currentPlayerState) return;
currentPlayerState.resetRequested = true;
try {
await serverApi.newGame.send({
game,
});
} catch (error) {
currentPlayerState.resetRequested = false;
if (isJazzRequestError(error)) {
toast.error(error.message);
} else {
console.error(error);
toast.error("An unexpected error occurred");
}
}
};
const playSelection = currentPlayerState?.currentSelection;
const submitDisabled =
!currentPlayerState?.currentSelection || currentPlayerState?.submitted;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-4">
<Gamepad2 className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
Rock, Paper, Scissors
</h1>
<Sparkles className="w-6 h-6 text-yellow-500" />
</div>
{/* Player Info */}
<div className="flex items-center justify-center gap-4 mb-6">
<Badge variant="secondary" className="px-4 py-2">
<Users className="w-4 h-4 mr-2" />
{isPlayer1 ? "Player 1" : "Player 2"}
</Badge>
</div>
{/* Score Board */}
<Card className="inline-block bg-white/80 backdrop-blur-sm border-0 shadow-lg">
<CardContent className="p-4">
<div className="flex items-center gap-6 text-2xl font-bold">
<div className="flex items-center gap-2">
<span className="text-blue-600">Player 1</span>
<span className="text-3xl">{game?.player1Score ?? 0}</span>
</div>
<div className="text-muted-foreground">-</div>
<div className="flex items-center gap-2">
<span className="text-3xl">{game?.player2Score ?? 0}</span>
<span className="text-purple-600">Player 2</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Game Outcome */}
{gameComplete && (
<Card className="mb-8 bg-gradient-to-r from-yellow-50 to-orange-50 border-yellow-200">
<CardContent className="p-6 text-center">
<div className="flex items-center justify-center gap-3 mb-4">
{getOutcomeIcon(game.outcome, player)}
<h2 className="text-2xl font-bold">
{game?.outcome === player
? "🎉 You Win! 🎉"
: game?.outcome === "draw"
? "🤝 It's a Draw! 🤝"
: "😔 You Lose! 😔"}
</h2>
</div>
<Button
disabled={currentPlayerState?.resetRequested}
onClick={handleNewGame}
className="bg-gradient-to-r from-primary to-purple-600 hover:from-primary/90 hover:to-purple-600/90 text-white"
size="lg"
>
<Gamepad2 className="w-5 h-5 mr-2" />
Start New Game
</Button>
</CardContent>
</Card>
)}
{/* Game Board */}
<div className="grid md:grid-cols-2 gap-8">
{/* Your Selection */}
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-lg">
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl font-semibold text-blue-600">
Your Selection
</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="mb-6">{playIcon(playSelection, "lg")}</div>
{!gameComplete && (
<>
{/* Choice Buttons */}
<div className="grid grid-cols-3 gap-4 mb-6">
<Button
variant={playSelection === "rock" ? "default" : "outline"}
size="lg"
className={`h-20 transition-all duration-200 ${
playSelection === "rock"
? "bg-gradient-to-br from-gray-400 to-gray-600 text-white shadow-lg scale-105"
: "hover:scale-105"
} text-3xl`}
onClick={() => handleSelection("rock")}
aria-label="Select Rock"
aria-selected={playSelection === "rock"}
>
🪨
</Button>
<Button
variant={
playSelection === "paper" ? "default" : "outline"
}
size="lg"
className={`h-20 transition-all duration-200 ${
playSelection === "paper"
? "bg-gradient-to-br from-blue-400 to-blue-600 text-white shadow-lg scale-105"
: "hover:scale-105"
} text-3xl`}
onClick={() => handleSelection("paper")}
aria-label="Select Paper"
aria-selected={playSelection === "paper"}
>
📄
</Button>
<Button
variant={
playSelection === "scissors" ? "default" : "outline"
}
size="lg"
className={`h-20 transition-all duration-200 ${
playSelection === "scissors"
? "bg-gradient-to-br from-red-400 to-red-600 text-white shadow-lg scale-105"
: "hover:scale-105"
} text-3xl`}
onClick={() => handleSelection("scissors")}
aria-label="Select Scissors"
aria-selected={playSelection === "scissors"}
>
</Button>
</div>
{/* Submit Button */}
<Button
disabled={submitDisabled}
onClick={() => handleSubmit(playSelection)}
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-3 text-lg"
size="lg"
>
{currentPlayerState?.submitted ? (
<>
<CheckCircle className="w-5 h-5 mr-2" />
Selection Made!
</>
) : (
<>
<Gamepad2 className="w-5 h-5 mr-2" />
Make Your Move!
</>
)}
</Button>
</>
)}
</CardContent>
</Card>
{/* Opponent Selection */}
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-lg">
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl font-semibold text-purple-600">
Opponent's Selection
</CardTitle>
</CardHeader>
<CardContent className="text-center">
{opponentSelection && (
<div className="mb-6">
{playIcon(opponentSelection?.value, "lg")}
</div>
)}
{!opponentSelection && !gameComplete ? (
opponentHasSelected ? (
<div className="text-muted-foreground">
<Clock className="w-8 h-8 mx-auto mb-2 animate-pulse" />
<p>The opponent has made their move</p>
</div>
) : (
<div className="text-muted-foreground">
<Clock className="w-8 h-8 mx-auto mb-2 animate-pulse" />
<p>Waiting for opponent...</p>
</div>
)
) : null}
</CardContent>
</Card>
</div>
{/* Game Status */}
{!gameComplete && (
<Card className="mt-8 bg-white/60 backdrop-blur-sm border-0">
<CardContent className="p-4 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Clock className="w-4 h-4" />
<span className="text-sm">
{currentPlayer?.playSelection && !opponentSelection
? "Waiting for opponent to make their move..."
: "Make your selection to start the game"}
</span>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -1,120 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,37 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import { Jazz } from "../jazz";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Jazz Server Side Validation",
description: "An example of server side validation with Jazz",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Jazz>{children}</Jazz>
<Toaster />
</body>
</html>
);
}

View File

@@ -1,97 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { serverApi } from "@/serverApi";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function HomeComponent() {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const onNewGameClick = async () => {
setIsLoading(true);
const { waitingRoom } = await serverApi.createGame.send({});
router.push(`/waiting-room/${waitingRoom.id}`);
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-900 flex flex-col items-center justify-center p-4 relative overflow-hidden">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
</div>
{/* Main content */}
<div className="relative z-10 text-center space-y-8 max-w-2xl mx-auto">
{/* Game title and emojis */}
<div className="space-y-4">
<div className="flex justify-center items-center space-x-4 text-6xl mb-6">
<span className="animate-bounce" style={{ animationDelay: "0ms" }}>
🪨
</span>
<span
className="animate-bounce"
style={{ animationDelay: "150ms" }}
>
📄
</span>
<span
className="animate-bounce"
style={{ animationDelay: "300ms" }}
>
</span>
</div>
<h1 className="text-5xl font-bold text-white mb-2">
Rock, Paper, Scissors
</h1>
<p className="text-xl text-gray-300 max-w-md mx-auto">
Challenge your friends in this classic multiplayer game powered by
Jazz
</p>
</div>
{/* Game card */}
<Card className="w-full max-w-md mx-auto bg-white/10 backdrop-blur-lg border-white/20 shadow-2xl">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-bold text-white">
Ready to Play?
</CardTitle>
<p className="text-gray-300 text-sm mt-2">
Create a new game and invite your friends
</p>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={onNewGameClick}
disabled={isLoading}
className="w-full h-12 text-lg font-semibold bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 border-0 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
>
{isLoading ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>Creating game...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>🎮</span>
<span>Start New Game</span>
</div>
)}
</Button>
</CardContent>
</Card>
{/* Footer */}
<div className="text-center text-gray-400 text-sm mt-12">
<p>Built with Jazz Framework</p>
</div>
</div>
</div>
);
}

View File

@@ -1,173 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { WaitingRoom } from "@/schema";
import { serverApi } from "@/serverApi";
import { Account, JazzRequestError, co } from "jazz-tools";
import { useCoState } from "jazz-tools/react-core";
import { ClipboardCopyIcon, Loader2Icon } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
function useWindowLocation() {
const [location, setLocation] = useState<string>("");
useEffect(() => {
setLocation(window.location.href);
}, []);
return location;
}
async function askToJoinGame(
waitingRoom: co.loaded<typeof WaitingRoom, { creator: true }>,
) {
if (waitingRoom.creator.isMe) {
return null;
}
try {
await serverApi.joinGame.send({
waitingRoom,
});
} catch (error) {
if (error instanceof JazzRequestError) {
toast.error(error.message);
} else {
toast.error("An unexpected error occurred");
}
}
}
export default function RouteComponent() {
const params = useParams<{ id: string }>();
const searchParams = useSearchParams();
const waitingRoom = useCoState(WaitingRoom, params.id, {
resolve: {
creator: true,
game: true,
},
});
const [copied, setCopied] = useState(false);
const router = useRouter();
const location = useWindowLocation();
const joinLocation = location + "?join=true";
const isJoining = !waitingRoom
? searchParams.get("join") === "true"
: Account.getMe().id !== waitingRoom.creator.id;
useEffect(() => {
if (!waitingRoom) {
return;
}
askToJoinGame(waitingRoom);
}, [waitingRoom?.id]);
useEffect(() => {
if (!waitingRoom?.game?.id) {
return;
}
router.push(`/game/${waitingRoom.game.id}`);
}, [waitingRoom?.game?.id]);
const onCopyClick = () => {
navigator.clipboard.writeText(joinLocation);
setCopied(true);
toast.success("Link copied to clipboard!");
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-900 flex flex-col items-center justify-center p-4 relative overflow-hidden">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
</div>
{/* Main content */}
<div className="relative z-10 text-center space-y-8 max-w-2xl mx-auto">
{/* Game title and emojis */}
<div className="space-y-4">
<div className="flex justify-center items-center space-x-4 text-6xl mb-6">
<span className="animate-bounce" style={{ animationDelay: "0ms" }}>
🪨
</span>
<span
className="animate-bounce"
style={{ animationDelay: "150ms" }}
>
📄
</span>
<span
className="animate-bounce"
style={{ animationDelay: "300ms" }}
>
</span>
</div>
<h1 className="text-5xl font-bold text-white mb-2">Waiting Room</h1>
{!isJoining && (
<p className="text-xl text-gray-300 max-w-md mx-auto">
Share this link with your friend to join the game
</p>
)}
</div>
{/* Waiting room card */}
<Card className="w-full max-w-md mx-auto bg-white/10 backdrop-blur-lg border-white/20 shadow-2xl">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-bold text-white flex items-center justify-center">
<Loader2Icon className="animate-spin inline h-8 w-8 mr-3" />
{isJoining ? "Joining the game" : "Waiting for opponent"}
</CardTitle>
<CardDescription className="text-gray-300 text-sm mt-2">
The game will automatically start once{" "}
{isJoining ? "ready" : "they join"}
</CardDescription>
</CardHeader>
{!isJoining && (
<CardContent className="space-y-4">
<div className="flex">
<Input
className="w-full border-white/20 bg-white/5 text-white placeholder:text-gray-400 rounded-e-none focus:border-white/40 focus:ring-white/20"
readOnly
value={joinLocation}
/>
<Button
onClick={onCopyClick}
className="rounded-s-none w-25 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 border-0 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
>
{copied ? (
"Copied!"
) : (
<>
<ClipboardCopyIcon className="w-5 h-5" />
Copy
</>
)}
</Button>
</div>
</CardContent>
)}
</Card>
{/* Footer */}
<div className="text-center text-gray-400 text-sm mt-12">
<p>Built with Jazz Framework</p>
</div>
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,46 +0,0 @@
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";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -1,59 +0,0 @@
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";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -1,92 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -1,24 +0,0 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -1,28 +0,0 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -1,25 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -1,15 +0,0 @@
import { JazzReactProvider } from "jazz-tools/react";
import { apiKey } from "./apiKey";
export function Jazz({ children }: { children: React.ReactNode }) {
return (
<JazzReactProvider
enableSSR
sync={{
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
}}
>
{children}
</JazzReactProvider>
);
}

View File

@@ -1,5 +0,0 @@
import { startWorker } from "jazz-tools/worker";
export const jazzServerAccount = await startWorker({
syncServer: "wss://cloud.jazz.tools/?key=jazz-paper-scissors@garden.co ",
});

View File

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

View File

@@ -1,94 +0,0 @@
import { Account, Group, co, z } from "jazz-tools";
export const PlayerState = co.map({
currentSelection: z.literal(["rock", "paper", "scissors"]).optional(),
submitted: z.boolean(),
resetRequested: z.boolean(),
});
export type PlayerState = co.loaded<typeof PlayerState>;
export const PlaySelection = co.map({
value: z.literal(["rock", "paper", "scissors"]),
group: Group,
});
export type PlaySelection = co.loaded<typeof PlaySelection>;
export const Player = co.map({
account: co.account(),
playSelection: PlaySelection.optional(),
});
export type Player = co.loaded<typeof Player>;
export const Game = co.map({
player1: Player,
player2: Player,
player1State: PlayerState,
player2State: PlayerState,
outcome: z.literal(["player1", "player2", "draw"]).optional(),
player1Score: z.number(),
player2Score: z.number(),
});
export type Game = co.loaded<typeof Game>;
export const WaitingRoom = co.map({
creator: co.account(),
game: co.optional(Game),
});
export type WaitingRoom = co.loaded<typeof WaitingRoom>;
export function createGameState(params: {
account1: Account;
account2: Account;
worker: Account;
}) {
const { account1, account2, worker } = params;
const gameGroup = Group.create({ owner: worker });
gameGroup.addMember(account1, "reader");
gameGroup.addMember(account2, "reader");
const player1 = Player.create(
{
account: account1,
},
gameGroup,
);
const player2 = Player.create(
{
account: account2,
},
gameGroup,
);
const player1StateGroup = Group.create(worker);
player1StateGroup.addMember(account1, "writer");
const player1State = PlayerState.create(
{
currentSelection: undefined,
submitted: false,
resetRequested: false,
},
player1StateGroup,
);
const player2StateGroup = Group.create(worker);
player2StateGroup.addMember(account2, "writer");
const player2State = PlayerState.create(
{
currentSelection: undefined,
submitted: false,
resetRequested: false,
},
player2StateGroup,
);
return {
player1,
player2,
player1State,
player2State,
outcome: undefined,
};
}

View File

@@ -1,125 +0,0 @@
import { experimental_defineRequest, z } from "jazz-tools";
import { Game, WaitingRoom } from "./schema";
const workerId = process.env.NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT!;
const createGameRequest = experimental_defineRequest({
url: "/api/create-game",
workerId,
request: {},
response: {
schema: { waitingRoom: WaitingRoom },
resolve: { waitingRoom: { creator: true } },
},
});
const joinGameRequest = experimental_defineRequest({
url: "/api/join-game",
workerId,
request: {
schema: {
waitingRoom: WaitingRoom,
},
resolve: {
waitingRoom: {
creator: true,
},
},
},
response: {
schema: {
waitingRoom: WaitingRoom,
},
resolve: {
waitingRoom: {
game: {
player1: {
account: true,
},
player2: {
account: true,
},
},
},
},
},
});
const newGameRequest = experimental_defineRequest({
url: "/api/new-game",
workerId,
request: {
schema: {
game: Game,
},
resolve: {
game: {
player1: {
account: true,
},
player2: {
account: true,
},
},
},
},
response: {
schema: {
game: Game,
},
resolve: {
game: {
player1State: {
$onError: null,
},
player2State: {
$onError: null,
},
player1: true,
player2: true,
},
},
},
});
const playRequest = experimental_defineRequest({
url: "/api/play",
workerId,
request: {
schema: {
game: Game,
selection: z.literal(["rock", "paper", "scissors"]),
},
resolve: {
game: {
player1: {
account: true,
playSelection: {
group: true,
},
},
player2: {
account: true,
playSelection: {
group: true,
},
},
},
},
},
response: {
schema: {
game: Game,
},
resolve: {
game: true,
},
},
});
export const serverApi = {
createGame: createGameRequest,
joinGame: joinGameRequest,
newGame: newGameRequest,
play: playRequest,
};

View File

@@ -1,37 +0,0 @@
import { expect, test } from "@playwright/test";
test("start a new game and play", async ({ page: marioPage, browser }) => {
await marioPage.goto("/");
await marioPage.getByRole("button", { name: /Start New Game/ }).click();
await expect(marioPage.getByText("Waiting for opponent")).toBeVisible();
const url = await marioPage.url();
const luigiContext = await browser.newContext();
const luigiPage = await luigiContext.newPage();
await luigiPage.goto(url);
await expect(marioPage.getByText("Waiting for selection")).toBeVisible();
await expect(luigiPage.getByText("Waiting for selection")).toBeVisible();
await expect(luigiPage.getByText("Waiting for opponent")).toBeVisible();
await marioPage.getByLabel("Select Rock").click();
await marioPage.getByRole("button", { name: /Make your move!/i }).click();
await expect(
luigiPage.getByText("The opponent has made their move"),
).toBeVisible();
await luigiPage.getByLabel("Select Paper").click();
await luigiPage.getByRole("button", { name: /Make your move!/i }).click();
await expect(luigiPage.getByText(/🎉 You Win! 🎉/)).toBeVisible();
await expect(marioPage.getByText(/😔 You Lose! 😔/)).toBeVisible();
await marioPage.getByRole("button", { name: /Start New Game/ }).click();
await expect(marioPage.getByText("Waiting for opponent")).toBeVisible();
await expect(luigiPage.getByText("Waiting for opponent")).toBeVisible();
});

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