Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 src/auth/sync/SyncManager.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useRealtimeGameSync } from '@/games/sync/useRealtimeGameSync';
import { useSyncLocalAndRemoteStore } from './useSyncLocalAndRemoteStore';

export interface ISyncManagerProps {}

export function SyncManager(props: ISyncManagerProps) {
useSyncLocalAndRemoteStore();
useRealtimeGameSync();

return null;
}
16 changes: 9 additions & 7 deletions src/core/zustand-helpers/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { produce } from 'immer';
import { produceWithPatches } from 'immer';
import type { RootState } from '@/core/zustand';
import { ImmerActions } from './immer';
import { emitStorePatches, ImmerActions } from './immer';
import type { Action } from './slices';

type InferActions<Actions> = Actions extends [infer ActionGroup, ...infer Rest]
Expand Down Expand Up @@ -49,11 +49,13 @@ export function withActions<
for (const group of actions) {
for (const [name, action] of Object.entries(group)) {
state[name] = (...args: any[]) => {
set(
produce(prevState =>
action(...args)(prevState, proxyGet(prevState)),
),
);
set(prevState => {
const [nextState, patches] = produceWithPatches(prevState, draft =>
action(...args)(draft as any, proxyGet(draft as any)),
);
if (patches.length > 0) emitStorePatches(patches);
return nextState;
});
};
(state[name] as any)[ImmerActions] = (state: State, ...args: any[]) => {
action(...args)(state, get);
Expand Down
21 changes: 21 additions & 0 deletions src/core/zustand-helpers/immer.ts
Original file line number Diff line number Diff line change
@@ -1 +1,22 @@
import { enablePatches, type Patch } from 'immer';

enablePatches();

export const ImmerActions = '__immerActions';

export type PatchListener = (patches: Patch[]) => void;

const listeners = new Set<PatchListener>();

export function onStorePatches(listener: PatchListener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

export function emitStorePatches(patches: Patch[]): void {
for (const listener of listeners) {
listener(patches);
}
}
14 changes: 9 additions & 5 deletions src/core/zustand-helpers/slices.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { produce, type WritableDraft } from 'immer';
import { ImmerActions } from './immer';
import { produceWithPatches, type WritableDraft } from 'immer';
import { emitStorePatches, ImmerActions } from './immer';

type InferState<Slices> = Slices extends [
SliceConfig<infer Name, infer State, infer Actions>,
Expand Down Expand Up @@ -30,9 +30,13 @@ export function withSlices<

for (const [name, action] of Object.entries(slice.actions)) {
state[name] = (...args: any[]) => {
set(
produce(prevState => action(...args)(prevState[slice.name], get)),
);
set(prevState => {
const [nextState, patches] = produceWithPatches(prevState, draft =>
action(...args)(draft[slice.name], get),
);
if (patches.length > 0) emitStorePatches(patches);
return nextState;
});
};
(state[name] as any)[ImmerActions] = (state: any, ...args: any[]) => {
action(...args)(state[slice.name], get);
Expand Down
3 changes: 2 additions & 1 deletion src/games/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ export interface Game {
shareToken?: string | null;
authorId?: string;
createdAt?: string;
updatedAt?: string;
}

export type GameRemoteData = Pick<
Tables<'games'>,
'author_id' | 'created_at' | 'id' | 'share_token'
'author_id' | 'created_at' | 'id' | 'share_token' | 'updated_at'
>;

export interface GameSettings {
Expand Down
1 change: 1 addition & 0 deletions src/games/gamesSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export const gamesSlice = createSlice({
state.games[gameId].createdAt = data.created_at;
state.games[gameId].savedId = data.id;
state.games[gameId].shareToken = data.share_token;
state.games[gameId].updatedAt = data.updated_at;
},
removeGameShareToken: (gameId: string) => state => {
state.games[gameId].shareToken = undefined;
Expand Down
209 changes: 110 additions & 99 deletions src/games/menu/GameMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Menu } from '@mantine/core';
import { Box, Button, Menu } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
Expand Down Expand Up @@ -55,6 +55,9 @@ export function GameMenu(props: IGameMenuProps) {
state => !!state.games.games[selectedId ?? '']?.savedId,
);
const isSaving = useStore(state => state.gameSave.isSaving);
const isSyncConnected =
useStore(state => state.gameSave.isRealtimeSyncConnected) ||
import.meta.env.DEV;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this || DEV?

const navigate = useNavigate();

const [opened, { toggle, open, close }] = useDisclosure();
Expand Down Expand Up @@ -93,113 +96,121 @@ export function GameMenu(props: IGameMenuProps) {

return (
<>
<Button.Group data-tutorial-id="games-menu">
<Menu>
<Menu.Target>
<Button
data-tutorial-id="games-menu-trigger"
loading={isSaving}
variant="light"
color="gray"
leftSection={<IconDeviceGamepad size={16} />}
rightSection={<IconChevronDown size={12} stroke={1.5} />}
>
{gameName ?? 'Select game'}
</Button>
</Menu.Target>
<Menu.Dropdown data-tutorial-id="games-menu-dropdown">
<Menu.Label>Change game</Menu.Label>
{gameOptions.map(option => (
<Menu.Item
key={option.value}
<Box pos="relative" style={{ display: 'inline-flex' }}>
<Button.Group data-tutorial-id="games-menu">
<Menu>
<Menu.Target>
<Button
data-tutorial-id="games-menu-trigger"
variant="light"
color="gray"
leftSection={<IconDeviceGamepad size={16} />}
rightSection={<IconChevronDown size={12} stroke={1.5} />}
>
{gameName ?? 'Select game'}
</Button>
</Menu.Target>
<Menu.Dropdown data-tutorial-id="games-menu-dropdown">
<Menu.Label>Change game</Menu.Label>
{gameOptions.map(option => (
<Menu.Item
key={option.value}
leftSection={<IconDeviceGamepad size={16} />}
onClick={() => {
useStore.getState().selectGame(option.value);
navigate(`/factories`);
}}
rightSection={
selectedId === option.value && (
<IconCircleFilled
size={8}
color="var(--mantine-color-green-7)"
/>
)
}
>
{option.label}
</Menu.Item>
))}

<Menu.Item
onClick={() => {
useStore.getState().selectGame(option.value);
navigate(`/factories`);
useStore.getState().createGame(v4(), {
name:
'New Game ' +
(Object.keys(useStore.getState().games.games).length + 1),
});
}}
rightSection={
selectedId === option.value && (
<IconCircleFilled
size={8}
color="var(--mantine-color-green-7)"
/>
)
leftSection={<IconPlus color="orange" size={16} />}
>
New game
</Menu.Item>
<Menu.Divider />
<Menu.Label>Game actions</Menu.Label>
<Menu.Item
leftSection={
<IconPencil color="var(--mantine-color-blue-3)" size={16} />
}
onClick={() => {
open();
}}
>
{option.label}
Rename game
</Menu.Item>
))}

<Menu.Item
onClick={() => {
useStore.getState().createGame(v4(), {
name:
'New Game ' +
(Object.keys(useStore.getState().games.games).length + 1),
});
}}
leftSection={<IconPlus color="orange" size={16} />}
>
New game
</Menu.Item>
<Menu.Divider />
<Menu.Label>Game actions</Menu.Label>
<Menu.Item
leftSection={
<IconPencil color="var(--mantine-color-blue-3)" size={16} />
}
onClick={() => {
open();
}}
>
Rename game
</Menu.Item>
<Menu.Item
leftSection={
<IconSettings color="var(--mantine-color-gray-5)" size={16} />
}
onClick={openGameSettingsModal}
>
Game settings
</Menu.Item>
<Menu.Item
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => handleSaveGame(selectedId)}
>
Save game
</Menu.Item>
{selectedId && isSelectedSavedOnRemote && (
<Menu.Item
leftSection={<IconDownload size={16} />}
onClick={() => handleLoadGame(selectedId)}
leftSection={
<IconSettings color="var(--mantine-color-gray-5)" size={16} />
}
onClick={openGameSettingsModal}
>
Game settings
</Menu.Item>
<Menu.Item
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => handleSaveGame(selectedId)}
>
Save game
</Menu.Item>
{selectedId && isSelectedSavedOnRemote && (
<Menu.Item
leftSection={<IconDownload size={16} />}
onClick={() => handleLoadGame(selectedId)}
>
Load last save
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
data-tutorial-id="games-menu-list"
leftSection={<IconList size={16} />}
onClick={() => {
navigate(`/games`);
}}
>
Load last save
Games list
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
data-tutorial-id="games-menu-list"
leftSection={<IconList size={16} />}
onClick={() => {
navigate(`/games`);
}}
>
Games list
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Button
data-tutorial-id="game-save-button"
className={cx(classes.gameMenuSecondaryButton)}
variant="light"
color="gray"
onClick={() => {
handleSaveGame(selectedId);
}}
>
<IconDeviceFloppy size={16} />
</Button>
</Button.Group>
</Menu.Dropdown>
</Menu>
<Button
data-tutorial-id="game-save-button"
className={cx(classes.gameMenuSecondaryButton)}
variant="light"
color="gray"
onClick={() => {
handleSaveGame(selectedId);
}}
>
<IconDeviceFloppy size={16} />
</Button>
</Button.Group>
{isSyncConnected && (
<IconCircleFilled
size={8}
color="var(--mantine-color-green-6)"
style={{ position: 'absolute', top: -2, right: -2 }}
/>
)}
</Box>
{selectedId && (
<GameDetailModal opened={opened} close={close} gameId={selectedId} />
)}
Expand Down
4 changes: 4 additions & 0 deletions src/games/save/gameSaveSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const gameSaveSlice = createSlice({
hasRehydratedLocalData: false,
isSaving: false,
isLoading: false,
isRealtimeSyncConnected: false,
},
actions: {
setIsSaving: (isSaving: boolean) => state => {
Expand All @@ -17,5 +18,8 @@ export const gameSaveSlice = createSlice({
setHasRehydratedLocalData: (hasRehydratedLocalData: boolean) => state => {
state.hasRehydratedLocalData = hasRehydratedLocalData;
},
setRealtimeSyncConnected: (isRealtimeSyncConnected: boolean) => state => {
state.isRealtimeSyncConnected = isRealtimeSyncConnected;
},
},
});
11 changes: 7 additions & 4 deletions src/games/save/saveRemoteGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { supabaseClient } from '@/core/supabase';
import { useStore } from '@/core/zustand';
import { serializeGame } from '@/games/store/gameFactoriesActions';

export async function saveRemoteGame(gameId?: string | null) {
export async function saveRemoteGame(
gameId?: string | null,
options?: { silent?: boolean },
) {
const { auth } = useStore.getState();
useStore.getState().setIsSaving(true);
if (!options?.silent) useStore.getState().setIsSaving(true);
try {
if (!auth.session) {
console.log(
Expand Down Expand Up @@ -41,7 +44,7 @@ export async function saveRemoteGame(gameId?: string | null) {
data: serializeGame(gameId) as unknown as Json,
updated_at: new Date().toISOString(),
})
.select('id, author_id, created_at, share_token')
.select('id, author_id, created_at, updated_at, share_token')
.single();

if (error) {
Expand All @@ -58,6 +61,6 @@ export async function saveRemoteGame(gameId?: string | null) {
message: error?.message ?? error ?? 'Unknown error',
});
} finally {
useStore.getState().setIsSaving(false);
if (!options?.silent) useStore.getState().setIsSaving(false);
}
}
Loading
Loading