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: 1 addition & 1 deletion src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function Modal({
<div className='fixed inset-0 z-50 flex w-screen items-start bg-gray-900/60 text-black transition-colors md:items-center md:p-4'>
<dialog
className={clsx(
'block h-full w-full bg-white p-0 md:mx-auto md:h-auto md:w-[640px] md:rounded',
'block h-full w-full bg-white p-0 md:mx-auto md:h-auto md:w-160 md:rounded',
className,
{
'overflow-y-scroll': scroll,
Expand Down
12 changes: 5 additions & 7 deletions src/components/PropsEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IconPlus, IconTrash } from '@tabler/icons-react'
import clsx from 'clsx'
import { useMemo, useState } from 'react'
import { ReactNode, useMemo, useState } from 'react'
import type { Prop } from '../entities/prop'
import { isMetaProp, metaPropKeyMap } from '../constants/metaProps'
import buildError from '../utils/buildError'
Expand All @@ -15,7 +15,7 @@ import TextInput from './TextInput'
type PropsEditorProps = {
startingProps: Prop[]
onSave: (props: Prop[]) => Promise<Prop[]>
noPropsMessage: string
noPropsMessage: ReactNode
}

type MetaProp = {
Expand Down Expand Up @@ -152,9 +152,7 @@ export default function PropsEditor({ startingProps, onSave, noPropsMessage }: P
</>
)}

{existingProps.length + newProps.length === 0 && (
<p>{noPropsMessage}. Click the button below to add one.</p>
)}
{existingProps.length + newProps.length === 0 && noPropsMessage}

{existingProps.length + newProps.length > 0 && (
<>
Expand All @@ -164,7 +162,7 @@ export default function PropsEditor({ startingProps, onSave, noPropsMessage }: P
{(prop) => (
<>
<TableCell
className={clsx('min-w-80', { '!rounded-bl-none': newProps.length > 0 })}
className={clsx('min-w-80', { 'rounded-bl-none!': newProps.length > 0 })}
>
{prop.key}
</TableCell>
Expand All @@ -177,7 +175,7 @@ export default function PropsEditor({ startingProps, onSave, noPropsMessage }: P
value={prop.value ?? ''}
/>
</TableCell>
<TableCell className={clsx({ '!rounded-br-none': newProps.length > 0 })}>
<TableCell className={clsx({ 'rounded-br-none!': newProps.length > 0 })}>
<Button
variant='icon'
className='ml-auto rounded-full bg-indigo-900 p-1'
Expand Down
19 changes: 15 additions & 4 deletions src/components/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,33 @@ type RadioGroupProps<T> = {
onChange: (value: T) => void
value?: T
info?: string
containerClassName?: string
itemClassName?: string
}

function RadioGroup<T>({ label, name, options, onChange, value, info }: RadioGroupProps<T>) {
function RadioGroup<T>({
label,
name,
options,
onChange,
value,
info,
containerClassName,
itemClassName,
}: RadioGroupProps<T>) {
const [focusedValue, setFocusedValue] = useState<T | null>(null)

return (
<fieldset className='w-full'>
{label && <legend className='mb-1 font-semibold'>{label}</legend>}
{info && <p className='mb-2 text-sm text-gray-500'>{info}</p>}

<div className='flex space-x-2'>
<div className={clsx('flex space-x-2', containerClassName)}>
{options.map((option, idx) => {
const selected = option.value === value

return (
<div key={String(option.value)} className='min-w-[96px]'>
<div key={String(option.value)} className={clsx('min-w-24', itemClassName)}>
<input
id={`${name}${idx}`}
className={hiddenInputStyle}
Expand All @@ -44,7 +55,7 @@ function RadioGroup<T>({ label, name, options, onChange, value, info }: RadioGro
htmlFor={`${name}${idx}`}
className={clsx(
'block cursor-pointer rounded border border-black/30 p-2 font-semibold transition-colors hover:bg-gray-100',
{ 'border-indigo-500 bg-indigo-500 hover:!bg-indigo-500': selected },
{ 'border-indigo-500 bg-indigo-500 hover:bg-indigo-500!': selected },
{ [labelFocusStyle]: option.value === focusedValue },
)}
>
Expand Down
4 changes: 2 additions & 2 deletions src/components/ServicesLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ function ServicesLink() {
},
{
name: 'Channels',
desc: 'Manage player communication channels',
desc: 'Manage channels and their storage pools',
icon: IconMessages,
route: routes.channels,
},
Expand All @@ -121,7 +121,7 @@ function ServicesLink() {
theme='services'
maxWidth=''
content={
<ul className='mb-4 grid gap-2 rounded border border-gray-700 bg-gray-800 p-2 text-white transition-all sm:min-w-[480px] sm:grid-cols-2'>
<ul className='mb-4 grid gap-2 rounded border border-gray-700 bg-gray-800 p-2 text-white transition-all sm:min-w-120 sm:grid-cols-2'>
{services
.filter(({ route }) => canViewPage(user, route))
.map(({ name, desc, icon: Icon, route }) => (
Expand Down
104 changes: 104 additions & 0 deletions src/components/empty-states/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { IconBook, IconExternalLink } from '@tabler/icons-react'
import { clsx } from 'clsx'
import { ReactNode, useCallback, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { DocsTypeSelection, DocType } from '../../modals/DocsTypeSelection'
import activeGameState, { SelectedActiveGame } from '../../state/activeGameState'
import useLocalStorage from '../../utils/useLocalStorage'
import Button from '../Button'

type Props = {
title: ReactNode
icon: ReactNode
children: ReactNode
learnMoreLink: string
docs: {
api: string
godot: string
unity: string
}
}

export function EmptyStateTitle({ children }: { children: ReactNode }) {
return <p className='text-xl font-medium'>{children}</p>
}

export function EmptyStateContent({
className,
children,
}: {
className?: string
children: ReactNode
}) {
return <div className={clsx('leading-relaxed text-gray-300', className)}>{children}</div>
}

export function EmptyStateButtons({
className,
learnMoreLink,
docs,
}: {
className?: string
learnMoreLink: Props['learnMoreLink']
docs: Props['docs']
}) {
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

const [showModal, setShowModal] = useState(false)
const [docsType, setDocsType] = useLocalStorage<DocType | null>(`${activeGame.id}-docsType`, null)

const handleLearnMoreClick = useCallback(() => {
window.open(learnMoreLink, '_blank')
}, [learnMoreLink])

const handleDocsClick = useCallback(() => {
if (docsType) {
window.open(docs[docsType], '_blank')
} else {
setShowModal(true)
}
}, [docs, docsType])

const handleDocTypeSelected = useCallback(
(selectedType: DocType) => {
setDocsType(selectedType)
setShowModal(false)
window.open(docs[selectedType], '_blank')
},
[docs, setDocsType],
)

return (
<>
<div className={clsx('flex justify-center gap-4', className)}>
<Button variant='grey' className='flex w-auto! gap-2' onClick={handleLearnMoreClick}>
<IconExternalLink />
<span>Learn more</span>
</Button>
<Button className='flex w-auto! gap-2' onClick={handleDocsClick}>
<IconBook />
<span>Go to docs</span>
</Button>
</div>
{showModal && (
<DocsTypeSelection
modalState={[showModal, setShowModal]}
onSelected={handleDocTypeSelected}
/>
)}
</>
)
}

export function EmptyState({ title, icon, children, learnMoreLink, docs }: Props) {
return (
<div className='mx-auto my-20 flex flex-col items-center justify-center gap-4 text-center lg:mb-0 lg:w-1/2 xl:w-1/3'>
<div className='flex size-16 items-center justify-center rounded-2xl border-2 border-indigo-400 bg-linear-to-b from-gray-800 to-gray-900 text-indigo-400 shadow-md'>
{icon}
</div>
<EmptyStateTitle>{title}</EmptyStateTitle>
<EmptyStateContent>{children}</EmptyStateContent>
<EmptyStateButtons learnMoreLink={learnMoreLink} docs={docs} />
</div>
)
}
26 changes: 26 additions & 0 deletions src/components/empty-states/NoChannels.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IconMessages } from '@tabler/icons-react'
import { useRecoilValue } from 'recoil'
import activeGameState, { SelectedActiveGame } from '../../state/activeGameState'
import { EmptyState } from './EmptyState'

export function NoChannels() {
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

return (
<EmptyState
title={`${activeGame.name} doesn't have any channels yet`}
icon={<IconMessages size={32} />}
learnMoreLink='https://trytalo.com/channels'
docs={{
api: 'https://docs.trytalo.com/docs/http/game-channel-api',
godot: 'https://docs.trytalo.com/docs/godot/channels',
unity: 'https://docs.trytalo.com/docs/unity/channels',
}}
>
<p>
Channels can be used for player chats, sending custom data and storing data in a shared
pool. Channels can be public, private, temporary or permanent.
</p>
</EmptyState>
)
}
22 changes: 22 additions & 0 deletions src/components/empty-states/NoEvents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { EmptyStateButtons, EmptyStateContent, EmptyStateTitle } from './EmptyState'

export function NoEvents() {
return (
<div className='mb-20 space-y-4 lg:mb-0'>
<EmptyStateTitle>There are no events for this date range</EmptyStateTitle>
<EmptyStateContent className='lg:w-1/2'>
Event tracking is a simple way to understand your players and their actions. When you track
an event, you can see how often it happens, when it happens and drill-down into its details.
</EmptyStateContent>
<EmptyStateButtons
className='justify-start'
learnMoreLink='https://trytalo.com/events'
docs={{
api: 'https://docs.trytalo.com/docs/http/event-api',
godot: 'https://docs.trytalo.com/docs/godot/events',
unity: 'https://docs.trytalo.com/docs/unity/events',
}}
/>
</div>
)
}
27 changes: 27 additions & 0 deletions src/components/empty-states/NoFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IconBubbleText } from '@tabler/icons-react'
import { useRecoilValue } from 'recoil'
import activeGameState, { SelectedActiveGame } from '../../state/activeGameState'
import { EmptyState } from './EmptyState'

export function NoFeedback() {
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

return (
<EmptyState
title={`${activeGame.name} doesn't have any feedback yet`}
icon={<IconBubbleText size={32} />}
learnMoreLink='https://trytalo.com/feedback'
docs={{
api: 'https://docs.trytalo.com/docs/http/game-feedback-api',
godot: 'https://docs.trytalo.com/docs/godot/feedback',
unity: 'https://docs.trytalo.com/docs/unity/feedback',
}}
>
<p>
Feedback from players can be filtered and sorted by categories and custom metadata. Feedback
can be anonymous or linked to a player, so you can see exactly what happened leading up to
it.
</p>
</EmptyState>
)
}
27 changes: 27 additions & 0 deletions src/components/empty-states/NoGroups.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IconSocial } from '@tabler/icons-react'
import { useRecoilValue } from 'recoil'
import activeGameState, { SelectedActiveGame } from '../../state/activeGameState'
import { EmptyState } from './EmptyState'

export function NoGroups() {
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

return (
<EmptyState
title={`${activeGame.name} doesn't have any groups yet`}
icon={<IconSocial size={32} />}
learnMoreLink='https://trytalo.com/players#groups'
docs={{
api: 'https://docs.trytalo.com/docs/http/player-group-api',
godot: 'https://docs.trytalo.com/docs/godot/groups',
unity: 'https://docs.trytalo.com/docs/unity/groups',
}}
>
<p>
Groups let you sort players based on stats, leaderboards, sessions or props. These segments
live-update and can be queried in-game, allow you to trigger custom logic for different
players.
</p>
</EmptyState>
)
}
27 changes: 27 additions & 0 deletions src/components/empty-states/NoLeaderboards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IconTrophy } from '@tabler/icons-react'
import { useRecoilValue } from 'recoil'
import activeGameState, { SelectedActiveGame } from '../../state/activeGameState'
import { EmptyState } from './EmptyState'

export function NoLeaderboards() {
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

return (
<EmptyState
title={`${activeGame.name} doesn't have any leaderboards yet`}
icon={<IconTrophy size={32} />}
learnMoreLink='https://trytalo.com/leaderboards'
docs={{
api: 'https://docs.trytalo.com/docs/http/leaderboard-api',
godot: 'https://docs.trytalo.com/docs/godot/leaderboards',
unity: 'https://docs.trytalo.com/docs/unity/leaderboards',
}}
>
<p>
Talo&apos;s leaderboards are highly customisable. Along with tracking scores, you can attach
custom metadata, enable timed refreshes (e.g. daily leaderboards) and hide suspicious
entries.
</p>
</EmptyState>
)
}
26 changes: 26 additions & 0 deletions src/components/empty-states/NoLiveConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useRecoilValue } from 'recoil'
import activeGameState, { SelectedActiveGame } from '../../state/activeGameState'
import { EmptyStateButtons, EmptyStateContent, EmptyStateTitle } from './EmptyState'

export function NoLiveConfig() {
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

return (
<>
<EmptyStateTitle>{activeGame.name} has no custom props</EmptyStateTitle>
<EmptyStateButtons
className='justify-start'
learnMoreLink='https://trytalo.com/live-config'
docs={{
api: 'https://docs.trytalo.com/docs/http/game-config-api',
godot: 'https://docs.trytalo.com/docs/godot/live-config',
unity: 'https://docs.trytalo.com/docs/unity/live-config',
}}
/>
<EmptyStateContent>
Live config is made up of custom props. These can be queried in-game to trigger custom logic
- like for seasonal events.
</EmptyStateContent>
</>
)
}
Loading