diff --git a/src/App.js b/src/App.js index c666293..f3b0fa2 100644 --- a/src/App.js +++ b/src/App.js @@ -11,6 +11,8 @@ import ReleaseNotes from "./routes/ReleaseNotes"; import { AuthProvider } from "./contexts/AuthContext"; import { DatabaseProvider } from "./contexts/DatabaseContext"; +import { MapDataProvider } from "./contexts/MapDataContext"; +import { TokenDataProvider } from "./contexts/TokenDataContext"; function App() { return ( @@ -29,7 +31,11 @@ function App() { - + + + + + diff --git a/src/components/ImageDrop.js b/src/components/ImageDrop.js new file mode 100644 index 0000000..da41987 --- /dev/null +++ b/src/components/ImageDrop.js @@ -0,0 +1,61 @@ +import React, { useState } from "react"; +import { Box, Flex, Text } from "theme-ui"; + +function ImageDrop({ onDrop, dropText, children }) { + const [dragging, setDragging] = useState(false); + function handleImageDragEnter(event) { + event.preventDefault(); + event.stopPropagation(); + setDragging(true); + } + + function handleImageDragLeave(event) { + event.preventDefault(); + event.stopPropagation(); + setDragging(false); + } + + function handleImageDrop(event) { + event.preventDefault(); + event.stopPropagation(); + const file = event.dataTransfer.files[0]; + if (file && file.type.startsWith("image")) { + onDrop(file); + } + setDragging(false); + } + + return ( + + {children} + {dragging && ( + { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }} + onDrop={handleImageDrop} + > + + {dropText || "Drop image to upload"} + + + )} + + ); +} + +export default ImageDrop; diff --git a/src/components/map/Map.js b/src/components/map/Map.js index a45244f..9754c37 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -13,6 +13,7 @@ import useDataSource from "../../helpers/useDataSource"; import MapInteraction from "./MapInteraction"; import AuthContext from "../../contexts/AuthContext"; +import TokenDataContext from "../../contexts/TokenDataContext"; import { mapSources as defaultMapSources } from "../../maps"; @@ -22,7 +23,6 @@ const mapTokenMenuClassName = "map-token__menu"; function Map({ map, mapState, - tokens, onMapTokenStateChange, onMapTokenStateRemove, onMapChange, @@ -39,6 +39,8 @@ function Map({ loading, }) { const { userId } = useContext(AuthContext); + const { tokens } = useContext(TokenDataContext); + const mapSource = useDataSource(map, defaultMapSources); function handleProxyDragEnd(isOnMap, tokenState) { diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js index 230d327..dcefccb 100644 --- a/src/components/map/MapSettings.js +++ b/src/components/map/MapSettings.js @@ -34,7 +34,7 @@ function MapSettings({ onChange={(e) => onSettingsChange("gridX", parseInt(e.target.value)) } - disabled={map === null || map.type === "default"} + disabled={!map || map.type === "default"} min={1} my={1} /> @@ -48,7 +48,7 @@ function MapSettings({ onChange={(e) => onSettingsChange("gridY", parseInt(e.target.value)) } - disabled={map === null || map.type === "default"} + disabled={!map || map.type === "default"} min={1} my={1} /> @@ -61,19 +61,15 @@ function MapSettings({ diff --git a/src/components/map/SelectMapButton.js b/src/components/map/SelectMapButton.js index 2e9dc87..536323f 100644 --- a/src/components/map/SelectMapButton.js +++ b/src/components/map/SelectMapButton.js @@ -5,12 +5,12 @@ import SelectMapModal from "../../modals/SelectMapModal"; import SelectMapIcon from "../../icons/SelectMapIcon"; function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) { - const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); function openModal() { - setIsAddModalOpen(true); + setIsModalOpen(true); } function closeModal() { - setIsAddModalOpen(false); + setIsModalOpen(false); } function handleDone() { @@ -27,7 +27,7 @@ function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) { + + + + + + ); +} + +export default SelectTokensButton; diff --git a/src/components/token/TokenTile.js b/src/components/token/TokenTile.js new file mode 100644 index 0000000..1eb9e75 --- /dev/null +++ b/src/components/token/TokenTile.js @@ -0,0 +1,59 @@ +import React from "react"; +import { Flex, Image, Text } from "theme-ui"; + +import useDataSource from "../../helpers/useDataSource"; + +import { tokenSources as defaultTokenSources } from "../../tokens"; + +function TokenTile({ token, isSelected }) { + const tokenSource = useDataSource(token, defaultTokenSources); + + return ( + + + + + {token.name} + + + + ); +} + +export default TokenTile; diff --git a/src/components/token/TokenTiles.js b/src/components/token/TokenTiles.js new file mode 100644 index 0000000..e9f77f1 --- /dev/null +++ b/src/components/token/TokenTiles.js @@ -0,0 +1,55 @@ +import React from "react"; +import { Flex } from "theme-ui"; +import SimpleBar from "simplebar-react"; + +import AddIcon from "../../icons/AddIcon"; + +import TokenTile from "./TokenTile"; + +function TokenTiles({ tokens, onTokenAdd }) { + return ( + + + + + + {tokens.map((token) => ( + + ))} + + + ); +} + +export default TokenTiles; diff --git a/src/components/token/Tokens.js b/src/components/token/Tokens.js index e4ae82a..20405f8 100644 --- a/src/components/token/Tokens.js +++ b/src/components/token/Tokens.js @@ -1,5 +1,5 @@ import React, { useState, useContext } from "react"; -import { Box } from "theme-ui"; +import { Box, Flex } from "theme-ui"; import shortid from "shortid"; import SimpleBar from "simplebar-react"; @@ -7,23 +7,27 @@ import ListToken from "./ListToken"; import ProxyToken from "./ProxyToken"; import NumberInput from "../NumberInput"; +import SelectTokensButton from "./SelectTokensButton"; + import { fromEntries } from "../../helpers/shared"; import AuthContext from "../../contexts/AuthContext"; +import TokenDataContext from "../../contexts/TokenDataContext"; const listTokenClassName = "list-token"; -function Tokens({ onCreateMapTokenState, tokens }) { - const [tokenSize, setTokenSize] = useState(1); +function Tokens({ onMapTokenStateCreate }) { const { userId } = useContext(AuthContext); + const { tokens } = useContext(TokenDataContext); + + const [tokenSize, setTokenSize] = useState(1); function handleProxyDragEnd(isOnMap, token) { - if (isOnMap && onCreateMapTokenState) { + if (isOnMap && onMapTokenStateCreate) { // Create a token state from the dragged token - onCreateMapTokenState({ + onMapTokenStateCreate({ id: shortid.generate(), tokenId: token.id, - type: token.type, owner: userId, size: tokenSize, label: "", @@ -44,7 +48,7 @@ function Tokens({ onCreateMapTokenState, tokens }) { overflow: "hidden", }} > - + {tokens.map((token) => ( ))} - - + + {/* - + /> */} + { + if (!userId || !database) { + return; + } + async function getDefaultMaps() { + const defaultMapsWithIds = []; + for (let i = 0; i < defaultMaps.length; i++) { + const defaultMap = defaultMaps[i]; + const id = `__default-${defaultMap.name}`; + defaultMapsWithIds.push({ + ...defaultMap, + id, + owner: userId, + // Emulate the time increasing to avoid sort errors + created: Date.now() + i, + lastModified: Date.now() + i, + gridType: "grid", + }); + // Add a state for the map if there isn't one already + const state = await database.table("states").get(id); + if (!state) { + await database.table("states").add({ ...defaultMapState, mapId: id }); + } + } + return defaultMapsWithIds; + } + + async function loadMaps() { + let storedMaps = await database + .table("maps") + .where({ owner: userId }) + .toArray(); + const sortedMaps = storedMaps.sort((a, b) => b.created - a.created); + const defaultMapsWithIds = await getDefaultMaps(); + const allMaps = [...sortedMaps, ...defaultMapsWithIds]; + setMaps(allMaps); + const storedStates = await database.table("states").toArray(); + setMapStates(storedStates); + } + + loadMaps(); + }, [userId, database]); + + async function addMap(map) { + await database.table("maps").add(map); + const state = { ...defaultMapState, mapId: map.id }; + await database.table("states").add(state); + setMaps((prevMaps) => [map, ...prevMaps]); + setMapStates((prevStates) => [state, ...prevStates]); + } + + async function removeMap(id) { + await database.table("maps").delete(id); + await database.table("states").delete(id); + setMaps((prevMaps) => { + const filtered = prevMaps.filter((map) => map.id !== id); + return filtered; + }); + setMapStates((prevMapsStates) => { + const filtered = prevMapsStates.filter((state) => state.mapId !== id); + return filtered; + }); + } + + async function resetMap(id) { + const state = { ...defaultMapState, mapId: id }; + await database.table("states").put(state); + setMapStates((prevMapStates) => { + const newStates = [...prevMapStates]; + const i = newStates.findIndex((state) => state.mapId === id); + if (i > -1) { + newStates[i] = state; + } + return newStates; + }); + return state; + } + + async function updateMap(id, update) { + const change = { ...update, lastModified: Date.now() }; + await database.table("maps").update(id, change); + setMaps((prevMaps) => { + const newMaps = [...prevMaps]; + const i = newMaps.findIndex((map) => map.id === id); + if (i > -1) { + newMaps[i] = { ...newMaps[i], ...change }; + } + return newMaps; + }); + } + + async function updateMapState(id, update) { + await database.table("states").update(id, update); + setMapStates((prevMapStates) => { + const newStates = [...prevMapStates]; + const i = newStates.findIndex((state) => state.mapId === id); + if (i > -1) { + newStates[i] = { ...newStates[i], ...update }; + } + return newStates; + }); + } + + const value = { + maps, + mapStates, + addMap, + removeMap, + resetMap, + updateMap, + updateMapState, + }; + return ( + {children} + ); +} + +export default MapDataContext; diff --git a/src/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js new file mode 100644 index 0000000..738d8e3 --- /dev/null +++ b/src/contexts/TokenDataContext.js @@ -0,0 +1,40 @@ +import React, { useEffect, useState, useContext } from "react"; + +import AuthContext from "./AuthContext"; +import DatabaseContext from "./DatabaseContext"; + +import { tokens as defaultTokens } from "../tokens"; + +const TokenDataContext = React.createContext(); + +export function TokenDataProvider({ children }) { + const { database } = useContext(DatabaseContext); + const { userId } = useContext(AuthContext); + + const [tokens, setTokens] = useState([]); + + useEffect(() => { + if (!userId) { + return; + } + const defaultTokensWithIds = []; + for (let defaultToken of defaultTokens) { + defaultTokensWithIds.push({ + ...defaultToken, + id: `__default-${defaultToken.key}`, + owner: userId, + }); + } + setTokens(defaultTokensWithIds); + }, [userId]); + + const value = { tokens }; + + return ( + + {children} + + ); +} + +export default TokenDataContext; diff --git a/src/icons/SelectTokensIcon.js b/src/icons/SelectTokensIcon.js new file mode 100644 index 0000000..0637422 --- /dev/null +++ b/src/icons/SelectTokensIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function SelectMapIcon() { + return ( + + + + + ); +} + +export default SelectMapIcon; diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index cb0cbcb..9a84cee 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -1,32 +1,18 @@ -import React, { useRef, useState, useEffect, useContext } from "react"; -import { Box, Button, Flex, Label, Text } from "theme-ui"; +import React, { useRef, useState, useContext } from "react"; +import { Button, Flex, Label } from "theme-ui"; import shortid from "shortid"; import Modal from "../components/Modal"; import MapTiles from "../components/map/MapTiles"; import MapSettings from "../components/map/MapSettings"; +import ImageDrop from "../components/ImageDrop"; -import AuthContext from "../contexts/AuthContext"; -import DatabaseContext from "../contexts/DatabaseContext"; - -import usePrevious from "../helpers/usePrevious"; import blobToBuffer from "../helpers/blobToBuffer"; -import { maps as defaultMaps } from "../maps"; +import MapDataContext from "../contexts/MapDataContext"; +import AuthContext from "../contexts/AuthContext"; const defaultMapSize = 22; -const defaultMapState = { - tokens: {}, - // An index into the draw actions array to which only actions before the - // index will be performed (used in undo and redo) - mapDrawActionIndex: -1, - mapDrawActions: [], - fogDrawActionIndex: -1, - fogDrawActions: [], - // Flags to determine what other people can edit - editFlags: ["drawing", "tokens"], -}; - const defaultMapProps = { // Grid type // TODO: add support for hex horizontal and hex vertical @@ -42,68 +28,26 @@ function SelectMapModal({ // The map currently being view in the map screen currentMap, }) { - const { database } = useContext(DatabaseContext); const { userId } = useContext(AuthContext); - - const wasOpen = usePrevious(isOpen); + const { + maps, + mapStates, + addMap, + removeMap, + resetMap, + updateMap, + updateMapState, + } = useContext(MapDataContext); const [imageLoading, setImageLoading] = useState(false); // The map selected in the modal - const [selectedMap, setSelectedMap] = useState(null); - const [selectedMapState, setSelectedMapState] = useState(null); - const [maps, setMaps] = useState([]); - // Load maps from the database and ensure state is properly setup - useEffect(() => { - if (!userId || !database) { - return; - } - async function getDefaultMaps() { - const defaultMapsWithIds = []; - for (let i = 0; i < defaultMaps.length; i++) { - const defaultMap = defaultMaps[i]; - const id = `__default-${defaultMap.name}`; - defaultMapsWithIds.push({ - ...defaultMap, - id, - owner: userId, - // Emulate the time increasing to avoid sort errors - created: Date.now() + i, - lastModified: Date.now() + i, - ...defaultMapProps, - }); - // Add a state for the map if there isn't one already - const state = await database.table("states").get(id); - if (!state) { - await database.table("states").add({ ...defaultMapState, mapId: id }); - } - } - return defaultMapsWithIds; - } + const [selectedMapId, setSelectedMapId] = useState(null); - async function loadMaps() { - let storedMaps = await database - .table("maps") - .where({ owner: userId }) - .toArray(); - const sortedMaps = storedMaps.sort((a, b) => b.created - a.created); - const defaultMapsWithIds = await getDefaultMaps(); - const allMaps = [...sortedMaps, ...defaultMapsWithIds]; - setMaps(allMaps); - - // reload map state as is may have changed while the modal was closed - if (selectedMap) { - const state = await database.table("states").get(selectedMap.id); - if (state) { - setSelectedMapState(state); - } - } - } - - if (!wasOpen && isOpen) { - loadMaps(); - } - }, [userId, database, isOpen, wasOpen, selectedMap]); + const selectedMap = maps.find((map) => map.id === selectedMapId); + const selectedMapState = mapStates.find( + (state) => state.mapId === selectedMapId + ); const fileInputRef = useRef(); @@ -180,108 +124,55 @@ function SelectMapModal({ } async function handleMapAdd(map) { - await database.table("maps").add(map); - const state = { ...defaultMapState, mapId: map.id }; - await database.table("states").add(state); - setMaps((prevMaps) => [map, ...prevMaps]); - setSelectedMap(map); - setSelectedMapState(state); + await addMap(map); + setSelectedMapId(map.id); } async function handleMapRemove(id) { - await database.table("maps").delete(id); - await database.table("states").delete(id); - setMaps((prevMaps) => { - const filtered = prevMaps.filter((map) => map.id !== id); - setSelectedMap(filtered[0]); - database.table("states").get(filtered[0].id).then(setSelectedMapState); - return filtered; - }); + await removeMap(id); + setSelectedMapId(null); // Removed the map from the map screen if needed - if (currentMap && currentMap.id === selectedMap.id) { + if (currentMap && currentMap.id === selectedMapId) { onMapChange(null, null); } } - async function handleMapSelect(map) { - const state = await database.table("states").get(map.id); - setSelectedMapState(state); - setSelectedMap(map); + function handleMapSelect(map) { + setSelectedMapId(map.id); } async function handleMapReset(id) { - const state = { ...defaultMapState, mapId: id }; - await database.table("states").put(state); - setSelectedMapState(state); + const newState = await resetMap(id); // Reset the state of the current map if needed - if (currentMap && currentMap.id === selectedMap.id) { - onMapStateChange(state); + if (currentMap && currentMap.id === selectedMapId) { + onMapStateChange(newState); } } - async function handleSubmit(e) { - e.preventDefault(); - if (selectedMap) { + async function handleDone() { + if (selectedMapId) { onMapChange(selectedMap, selectedMapState); onDone(); } onDone(); } - /** - * Drag and Drop - */ - const [dragging, setDragging] = useState(false); - function handleImageDragEnter(event) { - event.preventDefault(); - event.stopPropagation(); - setDragging(true); - } - - function handleImageDragLeave(event) { - event.preventDefault(); - event.stopPropagation(); - setDragging(false); - } - - function handleImageDrop(event) { - event.preventDefault(); - event.stopPropagation(); - const file = event.dataTransfer.files[0]; - if (file && file.type.startsWith("image")) { - handleImageUpload(file); - } - setDragging(false); - } - /** * Map settings */ const [showMoreSettings, setShowMoreSettings] = useState(false); async function handleMapSettingsChange(key, value) { - const change = { [key]: value, lastModified: Date.now() }; - database.table("maps").update(selectedMap.id, change); - const newMap = { ...selectedMap, ...change }; - setMaps((prevMaps) => { - const newMaps = [...prevMaps]; - const i = newMaps.findIndex((map) => map.id === selectedMap.id); - if (i > -1) { - newMaps[i] = newMap; - } - return newMaps; - }); - setSelectedMap(newMap); + await updateMap(selectedMapId, { [key]: value }); } async function handleMapStateSettingsChange(key, value) { - database.table("states").update(selectedMap.id, { [key]: value }); - setSelectedMapState((prevState) => ({ ...prevState, [key]: value })); + await updateMapState(selectedMapId, { [key]: value }); } return ( - + handleImageUpload(event.target.files[0])} type="file" @@ -305,7 +196,7 @@ function SelectMapModal({ selectedMapState={selectedMapState} onMapSelect={handleMapSelect} onMapReset={handleMapReset} - onSubmit={handleSubmit} + onDone={handleDone} /> - - {dragging && ( - { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "copy"; - }} - onDrop={handleImageDrop} - > - Drop map to upload - - )} - + ); } diff --git a/src/modals/SelectTokensModal.js b/src/modals/SelectTokensModal.js new file mode 100644 index 0000000..09a8133 --- /dev/null +++ b/src/modals/SelectTokensModal.js @@ -0,0 +1,48 @@ +import React, { useRef, useContext } from "react"; +import { Flex, Label } from "theme-ui"; + +import Modal from "../components/Modal"; +import ImageDrop from "../components/ImageDrop"; + +import TokenTiles from "../components/token/TokenTiles"; + +import TokenDataContext from "../contexts/TokenDataContext"; + +function SelectTokensModal({ isOpen, onRequestClose }) { + const { tokens } = useContext(TokenDataContext); + const fileInputRef = useRef(); + + function openImageDialog() { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + } + + function handleImageUpload(image) {} + + return ( + + + handleImageUpload(event.target.files[0])} + type="file" + accept="image/*" + style={{ display: "none" }} + ref={fileInputRef} + /> + + + + + + + ); +} + +export default SelectTokensModal; diff --git a/src/routes/Game.js b/src/routes/Game.js index 657eec0..390f0ff 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -17,8 +17,7 @@ import AuthModal from "../modals/AuthModal"; import AuthContext from "../contexts/AuthContext"; import DatabaseContext from "../contexts/DatabaseContext"; - -import { tokens as defaultTokens } from "../tokens"; +import MapDataContext from "../contexts/MapDataContext"; function Game() { const { database } = useContext(DatabaseContext); @@ -71,6 +70,7 @@ function Game() { } } + const { updateMapState } = useContext(MapDataContext); // Sync the map state to the database after 500ms of inactivity const debouncedMapState = useDebounce(mapState, 500); useEffect(() => { @@ -81,11 +81,9 @@ function Game() { map.owner === userId && database ) { - database - .table("states") - .update(debouncedMapState.mapId, debouncedMapState); + updateMapState(debouncedMapState.mapId, debouncedMapState); } - }, [map, debouncedMapState, userId, database]); + }, [map, debouncedMapState, userId, database, updateMapState]); function handleMapChange(newMap, newMapState) { setMapState(newMapState); @@ -116,7 +114,7 @@ function Game() { } } - async function handleMapTokenStateChange(token) { + function handleMapTokenStateChange(token) { if (mapState === null) { return; } @@ -438,30 +436,15 @@ function Game() { } }, [stream, peers, handleStreamEnd]); - /** - * Token data - */ - const [tokens, setTokens] = useState([]); - useEffect(() => { - if (!userId) { - return; - } - const defaultTokensWithIds = []; - for (let defaultToken of defaultTokens) { - defaultTokensWithIds.push({ - ...defaultToken, - id: `__default-${defaultToken.key}`, - owner: userId, - }); - } - setTokens(defaultTokensWithIds); - }, [userId]); - return ( <> - + setPeerError(null)}>