Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
PUBLIC_CLERK_KEY=""
PUBLIC_JAZZ_KEY=""
PUBLIC_JAZZ_WORKER_ACCOUNT=""
JAZZ_WORKER_SECRET=""
83 changes: 83 additions & 0 deletions app/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'

import { cn } from '@/utils/tailwind'

function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}

function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}

function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}

function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}

function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', className)}
{...props}
/>
)
}

function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}

function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />
}

function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-footer" className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
}

function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn('text-lg leading-none font-semibold', className)} {...props} />
}

function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return <DialogPrimitive.Description data-slot="dialog-description" className={cn('text-muted-foreground text-sm', className)} {...props} />
}

export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }
16 changes: 4 additions & 12 deletions app/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,13 @@ import type * as React from 'react'

import { cn } from '@/utils/tailwind'

type InputProps = React.ComponentProps<'input'> & { lang?: 'ru' | 'en' | undefined}
type InputProps = React.ComponentProps<'input'> & { lang?: 'ru' | 'en' | undefined }

function InputWithLang({ className, lang, ...props }: InputProps) {
return (
<div className='relative w-full'>
<div className='absolute left-2 top-1/2 -translate-y-1/2 select-none rounded-sm bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums'>
{lang}
</div>
<Input
className={cn(
lang ? 'pl-10' : '',
className,
)}
{...props}
/>
<div className="relative w-full">
<div className="absolute left-2 top-1/2 -translate-y-1/2 select-none rounded-sm bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums">{lang}</div>
<Input className={cn(lang ? 'pl-10' : '', className)} {...props} />
</div>
)
}
Expand Down
27 changes: 27 additions & 0 deletions app/jazz/requests/game/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { experimental_defineRequest, z } from 'jazz-tools'
import { GameSchema } from '@/schema'

const workerId = import.meta.env.PUBLIC_JAZZ_WORKER_ACCOUNT

export const createGameRequest = experimental_defineRequest({
url: '/api/game/create',
workerId,

request: {
schema: {
title: z.string(),
},

resolve: {},
},

response: {
schema: {
game: GameSchema,
},

resolve: {
game: true,
},
},
})
11 changes: 9 additions & 2 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { type RouteConfig, index, route, layout } from '@react-router/dev/routes

export default [
index('routes/home.tsx'),

layout('routes/app-layout.tsx', [route('dashboard', 'routes/dashboard.tsx'), route('games', 'routes/games.tsx'), route('games/:gameId', 'routes/game.tsx'), route('games/create', 'routes/game-create.tsx'), route('forms', 'routes/forms.tsx'), route('forms/:formId', 'routes/form.tsx')]),
route('api/game/create', 'routes/api/game/create.ts'),
layout('routes/app-layout.tsx', [
route('dashboard', 'routes/dashboard.tsx'),
route('games', 'routes/games.tsx'),
route('games/:gameId', 'routes/game.tsx'),
route('games/create', 'routes/game-create.tsx'),
route('forms', 'routes/forms.tsx'),
route('forms/:formId', 'routes/form.tsx'),
]),
] satisfies RouteConfig
37 changes: 37 additions & 0 deletions app/routes/api/game/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Route } from './+types/create'
import { Group } from 'jazz-tools'
import { jazz } from '@/server/jazz'
import { GameSchema, AppAccount } from '@/schema'
import { createGameRequest } from '@/jazz/requests/game/create'

export const action = async ({ request }: Route.ActionArgs) => {
return await createGameRequest.handle(request, jazz.worker, async (req, account) => {
const { title } = req

const ownerAccount = await AppAccount.load(account.$jazz.id, {
resolve: {
profile: true,
},
})

if (!ownerAccount) {
throw new Error('Owner account not found')
}

const group = Group.create(jazz.worker)
group.addMember(account, 'writer')
group.makePublic('reader')

const game = GameSchema.create(
{
owner: account,
title,
},
group,
)

return {
game,
}
})
}
13 changes: 9 additions & 4 deletions app/routes/app-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { useMemo } from 'react'
import { Outlet } from 'react-router'
import AppBar from '@/scopes/app/app-bar'
import ThemeProvider from '@/scopes/app/theme-provider'
import { useIsAuthenticated } from 'jazz-tools/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function AppLayout() {
const isAuthenticated = useIsAuthenticated()
const queryClient = useMemo(() => new QueryClient(), [])

return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<div className="min-h-dvh h-full grid grid-rows-[auto_1fr] overflow-hidden">
<AppBar />
<QueryClientProvider client={queryClient}>
<div className="min-h-dvh h-full grid grid-rows-[auto_1fr] overflow-hidden">
<AppBar />

{isAuthenticated && <Outlet />}
</div>
{isAuthenticated && <Outlet />}
</div>
</QueryClientProvider>
</ThemeProvider>
)
}
14 changes: 7 additions & 7 deletions app/routes/game-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import CreateGame from '@/scopes/games/create-game'
import { Page } from '@/components/ui/page'

export function meta(_: Route.MetaArgs) {
return [{ title: 'New Game' }, { name: 'description', content: 'description' }]
return [{ title: 'New Game' }, { name: 'description', content: 'description' }]
}

export default function GamePage() {
return (
<Page>
<CreateGame />
</Page>
)
}
return (
<Page>
<CreateGame />
</Page>
)
}
4 changes: 2 additions & 2 deletions app/routes/games.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Outlet } from 'react-router'
import { Games } from '@/scopes/games/Games'

export default function GamesPage() {
return <Outlet />
return <Games />
}
23 changes: 9 additions & 14 deletions app/schema.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import { co, z } from 'jazz-tools'

export const Game = co.map({
export const ProfileSchema = co.profile()

export const GameSchema = co.map({
title: z.string(),
owner: co.account(),
})

export const AppRoot = co.map({
games: co.list(Game),
games: co.list(GameSchema),
})

export const AppAccount = co
.account({
profile: co.profile(),
root: AppRoot,
})
.withMigration(async (account) => {
if (account.root === undefined) {
account.root = AppRoot.create({
games: co.list(Game).create([], { owner: account }),
})
}
})
export const AppAccount = co.account({
profile: ProfileSchema,
root: AppRoot,
})
78 changes: 78 additions & 0 deletions app/scopes/games/Games/CreateGameDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useId, useState } from 'react'
import type { GameSchema } from '@/schema'
import { useMutation } from '@tanstack/react-query'
import { createGameRequest } from '@/jazz/requests/game/create'
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import type { co } from 'jazz-tools'

type CreateGameDialogProps = {
onCreated: (game: co.loaded<typeof GameSchema>) => void
}

export const CreateGameDialog = (props: CreateGameDialogProps) => {
const { onCreated } = props
const titleInputId = useId()
const [title, setTitle] = useState('New game')

const createGameMutation = useMutation({
mutationFn: async () => {
const input = {
title: title.trim() || 'New game',
}

const { game } = await createGameRequest.send(input)
return game
},

onSuccess: async (game) => {
onCreated(game)
},
})

return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" size="lg">
CREATE GAME
</Button>
</DialogTrigger>

<DialogContent>
<DialogHeader>
<DialogTitle>Create a new game</DialogTitle>

<DialogDescription>Create a new game to get started.</DialogDescription>
</DialogHeader>

<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor={titleInputId}>Name</Label>

<Input id={titleInputId} name="title" value={title} onChange={(e) => setTitle(e.currentTarget.value)} />
</div>
</div>

<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Close
</Button>
</DialogClose>

<Button
type="button"
disabled={createGameMutation.isPending}
onClick={() => {
createGameMutation.mutate()
}}
>
Create game
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading