Added token edit modal, refactored map and token data providers

Refactored image drop code into reusable component as well
This commit is contained in:
Mitchell McCaffrey 2020-05-19 16:21:01 +10:00
parent 1774b459dc
commit 7b98370e4c
17 changed files with 567 additions and 238 deletions

View File

@ -11,6 +11,8 @@ import ReleaseNotes from "./routes/ReleaseNotes";
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from "./contexts/AuthContext";
import { DatabaseProvider } from "./contexts/DatabaseContext"; import { DatabaseProvider } from "./contexts/DatabaseContext";
import { MapDataProvider } from "./contexts/MapDataContext";
import { TokenDataProvider } from "./contexts/TokenDataContext";
function App() { function App() {
return ( return (
@ -29,7 +31,11 @@ function App() {
<FAQ /> <FAQ />
</Route> </Route>
<Route path="/game/:id"> <Route path="/game/:id">
<MapDataProvider>
<TokenDataProvider>
<Game /> <Game />
</TokenDataProvider>
</MapDataProvider>
</Route> </Route>
<Route path="/"> <Route path="/">
<Home /> <Home />

View File

@ -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 (
<Box onDragEnter={handleImageDragEnter}>
{children}
{dragging && (
<Flex
bg="overlay"
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
cursor: "copy",
}}
onDragLeave={handleImageDragLeave}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={handleImageDrop}
>
<Text sx={{ pointerEvents: "none" }}>
{dropText || "Drop image to upload"}
</Text>
</Flex>
)}
</Box>
);
}
export default ImageDrop;

View File

@ -13,6 +13,7 @@ import useDataSource from "../../helpers/useDataSource";
import MapInteraction from "./MapInteraction"; import MapInteraction from "./MapInteraction";
import AuthContext from "../../contexts/AuthContext"; import AuthContext from "../../contexts/AuthContext";
import TokenDataContext from "../../contexts/TokenDataContext";
import { mapSources as defaultMapSources } from "../../maps"; import { mapSources as defaultMapSources } from "../../maps";
@ -22,7 +23,6 @@ const mapTokenMenuClassName = "map-token__menu";
function Map({ function Map({
map, map,
mapState, mapState,
tokens,
onMapTokenStateChange, onMapTokenStateChange,
onMapTokenStateRemove, onMapTokenStateRemove,
onMapChange, onMapChange,
@ -39,6 +39,8 @@ function Map({
loading, loading,
}) { }) {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { tokens } = useContext(TokenDataContext);
const mapSource = useDataSource(map, defaultMapSources); const mapSource = useDataSource(map, defaultMapSources);
function handleProxyDragEnd(isOnMap, tokenState) { function handleProxyDragEnd(isOnMap, tokenState) {

View File

@ -34,7 +34,7 @@ function MapSettings({
onChange={(e) => onChange={(e) =>
onSettingsChange("gridX", parseInt(e.target.value)) onSettingsChange("gridX", parseInt(e.target.value))
} }
disabled={map === null || map.type === "default"} disabled={!map || map.type === "default"}
min={1} min={1}
my={1} my={1}
/> />
@ -48,7 +48,7 @@ function MapSettings({
onChange={(e) => onChange={(e) =>
onSettingsChange("gridY", parseInt(e.target.value)) onSettingsChange("gridY", parseInt(e.target.value))
} }
disabled={map === null || map.type === "default"} disabled={!map || map.type === "default"}
min={1} min={1}
my={1} my={1}
/> />
@ -61,19 +61,15 @@ function MapSettings({
<Flex my={1}> <Flex my={1}>
<Label> <Label>
<Checkbox <Checkbox
checked={ checked={mapState && mapState.editFlags.includes("fog")}
mapState !== null && mapState.editFlags.includes("fog") disabled={!mapState}
}
disabled={mapState === null}
onChange={(e) => handleFlagChange(e, "fog")} onChange={(e) => handleFlagChange(e, "fog")}
/> />
Fog Fog
</Label> </Label>
<Label> <Label>
<Checkbox <Checkbox
checked={ checked={mapState && mapState.editFlags.includes("drawing")}
mapState !== null && mapState.editFlags.includes("drawing")
}
disabled={mapState === null} disabled={mapState === null}
onChange={(e) => handleFlagChange(e, "drawing")} onChange={(e) => handleFlagChange(e, "drawing")}
/> />
@ -81,10 +77,8 @@ function MapSettings({
</Label> </Label>
<Label> <Label>
<Checkbox <Checkbox
checked={ checked={mapState && mapState.editFlags.includes("tokens")}
mapState !== null && mapState.editFlags.includes("tokens") disabled={!mapState}
}
disabled={mapState === null}
onChange={(e) => handleFlagChange(e, "tokens")} onChange={(e) => handleFlagChange(e, "tokens")}
/> />
Tokens Tokens
@ -97,7 +91,7 @@ function MapSettings({
name="name" name="name"
value={(map && map.name) || ""} value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)} onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={map === null || map.type === "default"} disabled={!map || map.type === "default"}
my={1} my={1}
/> />
</Box> </Box>
@ -115,7 +109,7 @@ function MapSettings({
}} }}
aria-label={showMore ? "Show Less" : "Show More"} aria-label={showMore ? "Show Less" : "Show More"}
title={showMore ? "Show Less" : "Show More"} title={showMore ? "Show Less" : "Show More"}
disabled={map === null} disabled={!map}
> >
<ExpandMoreIcon /> <ExpandMoreIcon />
</IconButton> </IconButton>

View File

@ -15,7 +15,7 @@ function MapTile({
onMapSelect, onMapSelect,
onMapRemove, onMapRemove,
onMapReset, onMapReset,
onSubmit, onDone,
}) { }) {
const mapSource = useDataSource(map, defaultMapSources); const mapSource = useDataSource(map, defaultMapSources);
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false); const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
@ -108,7 +108,7 @@ function MapTile({
}} }}
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (!isMapTileMenuOpen) { if (!isMapTileMenuOpen) {
onSubmit(e); onDone(e);
} }
}} }}
> >

View File

@ -17,7 +17,7 @@ function MapTiles({
onMapAdd, onMapAdd,
onMapRemove, onMapRemove,
onMapReset, onMapReset,
onSubmit, onDone,
}) { }) {
const { databaseStatus } = useContext(DatabaseContext); const { databaseStatus } = useContext(DatabaseContext);
return ( return (
@ -69,7 +69,7 @@ function MapTiles({
onMapSelect={onMapSelect} onMapSelect={onMapSelect}
onMapRemove={onMapRemove} onMapRemove={onMapRemove}
onMapReset={onMapReset} onMapReset={onMapReset}
onSubmit={onSubmit} onDone={onDone}
/> />
))} ))}
</Flex> </Flex>

View File

@ -5,12 +5,12 @@ import SelectMapModal from "../../modals/SelectMapModal";
import SelectMapIcon from "../../icons/SelectMapIcon"; import SelectMapIcon from "../../icons/SelectMapIcon";
function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) { function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() { function openModal() {
setIsAddModalOpen(true); setIsModalOpen(true);
} }
function closeModal() { function closeModal() {
setIsAddModalOpen(false); setIsModalOpen(false);
} }
function handleDone() { function handleDone() {
@ -27,7 +27,7 @@ function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
<SelectMapIcon /> <SelectMapIcon />
</IconButton> </IconButton>
<SelectMapModal <SelectMapModal
isOpen={isAddModalOpen} isOpen={isModalOpen}
onRequestClose={closeModal} onRequestClose={closeModal}
onDone={handleDone} onDone={handleDone}
onMapChange={onMapChange} onMapChange={onMapChange}

View File

@ -0,0 +1,38 @@
import React, { useState } from "react";
import { IconButton } from "theme-ui";
import SelectTokensIcon from "../../icons/SelectTokensIcon";
import SelectTokensModal from "../../modals/SelectTokensModal";
function SelectTokensButton() {
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
setIsModalOpen(true);
}
function closeModal() {
setIsModalOpen(false);
}
function handleDone() {
closeModal();
}
return (
<>
<IconButton
aria-label="Edit Tokens"
title="Edit Tokens"
onClick={openModal}
>
<SelectTokensIcon />
</IconButton>
<SelectTokensModal
isOpen={isModalOpen}
onRequestClose={closeModal}
onDone={handleDone}
/>
</>
);
}
export default SelectTokensButton;

View File

@ -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 (
<Flex
sx={{
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
position: "relative",
width: "150px",
height: "150px",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
}}
m={2}
bg="muted"
>
<Image
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
src={tokenSource}
/>
<Flex
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
alignItems: "flex-end",
justifyContent: "center",
}}
p={2}
>
<Text
as="p"
variant="heading"
color="hsl(210, 50%, 96%)"
sx={{ textAlign: "center" }}
>
{token.name}
</Text>
</Flex>
</Flex>
);
}
export default TokenTile;

View File

@ -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 (
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
<Flex
py={2}
bg="muted"
sx={{
flexWrap: "wrap",
width: "500px",
borderRadius: "4px",
}}
>
<Flex
onClick={onTokenAdd}
sx={{
":hover": {
color: "primary",
},
":focus": {
outline: "none",
},
":active": {
color: "secondary",
},
width: "150px",
height: "150px",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
}}
m={2}
bg="muted"
aria-label="Add Token"
title="Add Token"
>
<AddIcon large />
</Flex>
{tokens.map((token) => (
<TokenTile key={token.id} token={token} />
))}
</Flex>
</SimpleBar>
);
}
export default TokenTiles;

View File

@ -1,5 +1,5 @@
import React, { useState, useContext } from "react"; import React, { useState, useContext } from "react";
import { Box } from "theme-ui"; import { Box, Flex } from "theme-ui";
import shortid from "shortid"; import shortid from "shortid";
import SimpleBar from "simplebar-react"; import SimpleBar from "simplebar-react";
@ -7,23 +7,27 @@ import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken"; import ProxyToken from "./ProxyToken";
import NumberInput from "../NumberInput"; import NumberInput from "../NumberInput";
import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared"; import { fromEntries } from "../../helpers/shared";
import AuthContext from "../../contexts/AuthContext"; import AuthContext from "../../contexts/AuthContext";
import TokenDataContext from "../../contexts/TokenDataContext";
const listTokenClassName = "list-token"; const listTokenClassName = "list-token";
function Tokens({ onCreateMapTokenState, tokens }) { function Tokens({ onMapTokenStateCreate }) {
const [tokenSize, setTokenSize] = useState(1);
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { tokens } = useContext(TokenDataContext);
const [tokenSize, setTokenSize] = useState(1);
function handleProxyDragEnd(isOnMap, token) { function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onCreateMapTokenState) { if (isOnMap && onMapTokenStateCreate) {
// Create a token state from the dragged token // Create a token state from the dragged token
onCreateMapTokenState({ onMapTokenStateCreate({
id: shortid.generate(), id: shortid.generate(),
tokenId: token.id, tokenId: token.id,
type: token.type,
owner: userId, owner: userId,
size: tokenSize, size: tokenSize,
label: "", label: "",
@ -44,7 +48,7 @@ function Tokens({ onCreateMapTokenState, tokens }) {
overflow: "hidden", overflow: "hidden",
}} }}
> >
<SimpleBar style={{ height: "calc(100% - 58px)", overflowX: "hidden" }}> <SimpleBar style={{ height: "calc(100% - 48px)", overflowX: "hidden" }}>
{tokens.map((token) => ( {tokens.map((token) => (
<ListToken <ListToken
key={token.id} key={token.id}
@ -53,15 +57,23 @@ function Tokens({ onCreateMapTokenState, tokens }) {
/> />
))} ))}
</SimpleBar> </SimpleBar>
<Box pt={1} bg="muted" sx={{ height: "58px" }}> <Flex
<NumberInput bg="muted"
sx={{
justifyContent: "center",
height: "48px",
alignItems: "center",
}}
>
<SelectTokensButton />
{/* <NumberInput
value={tokenSize} value={tokenSize}
onChange={setTokenSize} onChange={setTokenSize}
title="Size" title="Size"
min={1} min={1}
max={9} max={9}
/> /> */}
</Box> </Flex>
</Box> </Box>
<ProxyToken <ProxyToken
tokenClassName={listTokenClassName} tokenClassName={listTokenClassName}

View File

@ -0,0 +1,146 @@
import React, { useEffect, useState, useContext } from "react";
import AuthContext from "./AuthContext";
import DatabaseContext from "./DatabaseContext";
import { maps as defaultMaps } from "../maps";
const MapDataContext = React.createContext();
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"],
};
export function MapDataProvider({ children }) {
const { database } = useContext(DatabaseContext);
const { userId } = useContext(AuthContext);
const [maps, setMaps] = useState([]);
const [mapStates, setMapStates] = 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,
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 (
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
);
}
export default MapDataContext;

View File

@ -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 (
<TokenDataContext.Provider value={value}>
{children}
</TokenDataContext.Provider>
);
}
export default TokenDataContext;

View File

@ -0,0 +1,18 @@
import React from "react";
function SelectMapIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4 11h-3v3c0 .55-.45 1-1 1s-1-.45-1-1v-3H8c-.55 0-1-.45-1-1s.45-1 1-1h3V8c0-.55.45-1 1-1s1 .45 1 1v3h3c.55 0 1 .45 1 1s-.45 1-1 1z" />
</svg>
);
}
export default SelectMapIcon;

View File

@ -1,32 +1,18 @@
import React, { useRef, useState, useEffect, useContext } from "react"; import React, { useRef, useState, useContext } from "react";
import { Box, Button, Flex, Label, Text } from "theme-ui"; import { Button, Flex, Label } from "theme-ui";
import shortid from "shortid"; import shortid from "shortid";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles"; import MapTiles from "../components/map/MapTiles";
import MapSettings from "../components/map/MapSettings"; 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 blobToBuffer from "../helpers/blobToBuffer";
import { maps as defaultMaps } from "../maps"; import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
const defaultMapSize = 22; 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 = { const defaultMapProps = {
// Grid type // Grid type
// TODO: add support for hex horizontal and hex vertical // TODO: add support for hex horizontal and hex vertical
@ -42,68 +28,26 @@ function SelectMapModal({
// The map currently being view in the map screen // The map currently being view in the map screen
currentMap, currentMap,
}) { }) {
const { database } = useContext(DatabaseContext);
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const {
const wasOpen = usePrevious(isOpen); maps,
mapStates,
addMap,
removeMap,
resetMap,
updateMap,
updateMapState,
} = useContext(MapDataContext);
const [imageLoading, setImageLoading] = useState(false); const [imageLoading, setImageLoading] = useState(false);
// The map selected in the modal // The map selected in the modal
const [selectedMap, setSelectedMap] = useState(null); const [selectedMapId, setSelectedMapId] = 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;
}
async function loadMaps() { const selectedMap = maps.find((map) => map.id === selectedMapId);
let storedMaps = await database const selectedMapState = mapStates.find(
.table("maps") (state) => state.mapId === selectedMapId
.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 fileInputRef = useRef(); const fileInputRef = useRef();
@ -180,108 +124,55 @@ function SelectMapModal({
} }
async function handleMapAdd(map) { async function handleMapAdd(map) {
await database.table("maps").add(map); await addMap(map);
const state = { ...defaultMapState, mapId: map.id }; setSelectedMapId(map.id);
await database.table("states").add(state);
setMaps((prevMaps) => [map, ...prevMaps]);
setSelectedMap(map);
setSelectedMapState(state);
} }
async function handleMapRemove(id) { async function handleMapRemove(id) {
await database.table("maps").delete(id); await removeMap(id);
await database.table("states").delete(id); setSelectedMapId(null);
setMaps((prevMaps) => {
const filtered = prevMaps.filter((map) => map.id !== id);
setSelectedMap(filtered[0]);
database.table("states").get(filtered[0].id).then(setSelectedMapState);
return filtered;
});
// Removed the map from the map screen if needed // Removed the map from the map screen if needed
if (currentMap && currentMap.id === selectedMap.id) { if (currentMap && currentMap.id === selectedMapId) {
onMapChange(null, null); onMapChange(null, null);
} }
} }
async function handleMapSelect(map) { function handleMapSelect(map) {
const state = await database.table("states").get(map.id); setSelectedMapId(map.id);
setSelectedMapState(state);
setSelectedMap(map);
} }
async function handleMapReset(id) { async function handleMapReset(id) {
const state = { ...defaultMapState, mapId: id }; const newState = await resetMap(id);
await database.table("states").put(state);
setSelectedMapState(state);
// Reset the state of the current map if needed // Reset the state of the current map if needed
if (currentMap && currentMap.id === selectedMap.id) { if (currentMap && currentMap.id === selectedMapId) {
onMapStateChange(state); onMapStateChange(newState);
} }
} }
async function handleSubmit(e) { async function handleDone() {
e.preventDefault(); if (selectedMapId) {
if (selectedMap) {
onMapChange(selectedMap, selectedMapState); onMapChange(selectedMap, selectedMapState);
onDone(); onDone();
} }
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 * Map settings
*/ */
const [showMoreSettings, setShowMoreSettings] = useState(false); const [showMoreSettings, setShowMoreSettings] = useState(false);
async function handleMapSettingsChange(key, value) { async function handleMapSettingsChange(key, value) {
const change = { [key]: value, lastModified: Date.now() }; await updateMap(selectedMapId, { [key]: value });
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);
} }
async function handleMapStateSettingsChange(key, value) { async function handleMapStateSettingsChange(key, value) {
database.table("states").update(selectedMap.id, { [key]: value }); await updateMapState(selectedMapId, { [key]: value });
setSelectedMapState((prevState) => ({ ...prevState, [key]: value }));
} }
return ( return (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}> <Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<Box as="form" onSubmit={handleSubmit} onDragEnter={handleImageDragEnter}> <ImageDrop onDrop={handleImageUpload} dropText="Drop map to upload">
<input <input
onChange={(event) => handleImageUpload(event.target.files[0])} onChange={(event) => handleImageUpload(event.target.files[0])}
type="file" type="file"
@ -305,7 +196,7 @@ function SelectMapModal({
selectedMapState={selectedMapState} selectedMapState={selectedMapState}
onMapSelect={handleMapSelect} onMapSelect={handleMapSelect}
onMapReset={handleMapReset} onMapReset={handleMapReset}
onSubmit={handleSubmit} onDone={handleDone}
/> />
<MapSettings <MapSettings
map={selectedMap} map={selectedMap}
@ -315,35 +206,15 @@ function SelectMapModal({
showMore={showMoreSettings} showMore={showMoreSettings}
onShowMoreChange={setShowMoreSettings} onShowMoreChange={setShowMoreSettings}
/> />
<Button variant="primary" disabled={imageLoading}> <Button
variant="primary"
disabled={imageLoading}
onClick={handleDone}
>
Done Done
</Button> </Button>
{dragging && (
<Flex
bg="muted"
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
cursor: "copy",
}}
onDragLeave={handleImageDragLeave}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={handleImageDrop}
>
<Text sx={{ pointerEvents: "none" }}>Drop map to upload</Text>
</Flex> </Flex>
)} </ImageDrop>
</Flex>
</Box>
</Modal> </Modal>
); );
} }

View File

@ -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 (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<ImageDrop onDrop={handleImageUpload} dropText="Drop token to upload">
<input
onChange={(event) => handleImageUpload(event.target.files[0])}
type="file"
accept="image/*"
style={{ display: "none" }}
ref={fileInputRef}
/>
<Flex
sx={{
flexDirection: "column",
}}
>
<Label pt={2} pb={1}>
Edit or import a token
</Label>
<TokenTiles tokens={tokens} onTokenAdd={openImageDialog} />
</Flex>
</ImageDrop>
</Modal>
);
}
export default SelectTokensModal;

View File

@ -17,8 +17,7 @@ import AuthModal from "../modals/AuthModal";
import AuthContext from "../contexts/AuthContext"; import AuthContext from "../contexts/AuthContext";
import DatabaseContext from "../contexts/DatabaseContext"; import DatabaseContext from "../contexts/DatabaseContext";
import MapDataContext from "../contexts/MapDataContext";
import { tokens as defaultTokens } from "../tokens";
function Game() { function Game() {
const { database } = useContext(DatabaseContext); 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 // Sync the map state to the database after 500ms of inactivity
const debouncedMapState = useDebounce(mapState, 500); const debouncedMapState = useDebounce(mapState, 500);
useEffect(() => { useEffect(() => {
@ -81,11 +81,9 @@ function Game() {
map.owner === userId && map.owner === userId &&
database database
) { ) {
database updateMapState(debouncedMapState.mapId, debouncedMapState);
.table("states")
.update(debouncedMapState.mapId, debouncedMapState);
} }
}, [map, debouncedMapState, userId, database]); }, [map, debouncedMapState, userId, database, updateMapState]);
function handleMapChange(newMap, newMapState) { function handleMapChange(newMap, newMapState) {
setMapState(newMapState); setMapState(newMapState);
@ -116,7 +114,7 @@ function Game() {
} }
} }
async function handleMapTokenStateChange(token) { function handleMapTokenStateChange(token) {
if (mapState === null) { if (mapState === null) {
return; return;
} }
@ -438,30 +436,15 @@ function Game() {
} }
}, [stream, peers, handleStreamEnd]); }, [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 ( return (
<> <>
<Flex sx={{ flexDirection: "column", height: "100%" }}> <Flex sx={{ flexDirection: "column", height: "100%" }}>
<Flex <Flex
sx={{ justifyContent: "space-between", flexGrow: 1, height: "100%" }} sx={{
justifyContent: "space-between",
flexGrow: 1,
height: "100%",
}}
> >
<Party <Party
nickname={nickname} nickname={nickname}
@ -476,7 +459,6 @@ function Game() {
<Map <Map
map={map} map={map}
mapState={mapState} mapState={mapState}
tokens={tokens}
loading={mapLoading} loading={mapLoading}
onMapTokenStateChange={handleMapTokenStateChange} onMapTokenStateChange={handleMapTokenStateChange}
onMapTokenStateRemove={handleMapTokenStateRemove} onMapTokenStateRemove={handleMapTokenStateRemove}
@ -492,10 +474,7 @@ function Game() {
allowFogDrawing={canEditFogDrawing} allowFogDrawing={canEditFogDrawing}
disabledTokens={disabledMapTokens} disabledTokens={disabledMapTokens}
/> />
<Tokens <Tokens onMapTokenStateCreate={handleMapTokenStateChange} />
tokens={tokens}
onCreateMapTokenState={handleMapTokenStateChange}
/>
</Flex> </Flex>
</Flex> </Flex>
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}> <Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>