Added map group support

This commit is contained in:
Mitchell McCaffrey 2020-10-01 15:05:30 +10:00
parent a67ff4f407
commit 3215efffa3
6 changed files with 252 additions and 50 deletions

View File

@ -1,13 +1,15 @@
import React, { useContext } from "react";
import { Flex, Box, Text, IconButton, Close } from "theme-ui";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useMedia } from "react-media";
import Case from "case";
import AddIcon from "../../icons/AddIcon";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import ResetMapIcon from "../../icons/ResetMapIcon";
import SelectMultipleIcon from "../../icons/SelectMultipleIcon";
import SelectSingleIcon from "../../icons/SelectSingleIcon";
import GroupIcon from "../../icons/GroupIcon";
import RadioIconButton from "./controls/RadioIconButton";
@ -19,6 +21,7 @@ import DatabaseContext from "../../contexts/DatabaseContext";
function MapTiles({
maps,
groups,
selectedMaps,
selectedMapStates,
onMapSelect,
@ -31,6 +34,7 @@ function MapTiles({
onSelectModeChange,
search,
onSearchChange,
onMapsGroup,
}) {
const { databaseStatus } = useContext(DatabaseContext);
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
@ -55,6 +59,26 @@ function MapTiles({
}
}
function mapToTile(map) {
const isSelected = selectedMaps.includes(map);
return (
<MapTile
key={map.id}
map={map}
isSelected={isSelected}
onMapSelect={onMapSelect}
onMapEdit={onMapEdit}
onDone={onDone}
large={isSmallScreen}
canEdit={
isSelected && selectMode === "single" && selectedMaps.length === 1
}
/>
);
}
const multipleSelected = selectedMaps.length > 1;
return (
<Box sx={{ position: "relative" }}>
<Flex
@ -116,28 +140,19 @@ function MapTiles({
flexWrap: "wrap",
borderRadius: "4px",
minHeight: "400px",
alignContent: "flex-start",
}}
onClick={() => onMapSelect()}
>
{maps.map((map) => {
const isSelected = selectedMaps.includes(map);
return (
<MapTile
key={map.id}
map={map}
isSelected={isSelected}
onMapSelect={onMapSelect}
onMapEdit={onMapEdit}
onDone={onDone}
large={isSmallScreen}
canEdit={
isSelected &&
selectMode === "single" &&
selectedMaps.length === 1
}
/>
);
})}
{/* Render ungrouped maps, grouped maps then default maps */}
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{maps[group].map(mapToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (
@ -176,16 +191,24 @@ function MapTiles({
/>
<Flex>
<IconButton
aria-label="Reset Map"
title="Reset Map"
aria-label={multipleSelected ? "Group Maps" : "Group Map"}
title={multipleSelected ? "Group Maps" : "Group Map"}
onClick={() => onMapsGroup()}
disabled={hasSelectedDefaultMap}
>
<GroupIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Reset Maps" : "Reset Map"}
title={multipleSelected ? "Reset Maps" : "Reset Map"}
onClick={() => onMapsReset()}
disabled={!hasMapState}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label="Remove Map"
title="Remove Map"
aria-label={multipleSelected ? "Remove Maps" : "Remove Map"}
title={multipleSelected ? "Remove Maps" : "Remove Map"}
onClick={() => onMapsRemove()}
disabled={hasSelectedDefaultMap}
>

View File

@ -48,6 +48,7 @@ export function MapDataProvider({ children }) {
gridType: "grid",
showGrid: false,
snapToGrid: true,
group: "default",
});
// Add a state for the map if there isn't one already
const state = await database.table("states").get(id);

View File

@ -187,7 +187,7 @@ function loadVersions(db) {
// v1.5.2 - Added automatic cache invalidation to maps
db.version(11)
.stores({})
.upgrade(async (tx) => {
.upgrade((tx) => {
return tx
.table("maps")
.toCollection()
@ -198,7 +198,7 @@ function loadVersions(db) {
// v1.5.2 - Added automatic cache invalidation to tokens
db.version(12)
.stores({})
.upgrade(async (tx) => {
.upgrade((tx) => {
return tx
.table("tokens")
.toCollection()
@ -206,6 +206,17 @@ function loadVersions(db) {
token.lastUsed = token.lastModified;
});
});
// v1.6.0 - Added map grouping
db.version(13)
.stores({})
.upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.group = "";
});
});
}
// Get the dexie database used in DatabaseContext

18
src/icons/GroupIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function GroupIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M20 6h-8l-1.41-1.41C10.21 4.21 9.7 4 9.17 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 12H5c-.55 0-1-.45-1-1V9c0-.55.45-1 1-1h14c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1z" />
</svg>
);
}
export default GroupIcon;

View File

@ -0,0 +1,65 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Label, Flex } from "theme-ui";
import Modal from "../components/Modal";
import Select from "../components/Select";
function EditGroupModal({
isOpen,
onRequestClose,
onChange,
groups,
defaultGroup,
}) {
const [value, setValue] = useState();
const [options, setOptions] = useState([]);
useEffect(() => {
if (defaultGroup) {
setValue({ value: defaultGroup, label: defaultGroup });
} else {
setValue();
}
}, [defaultGroup]);
useEffect(() => {
setOptions(groups.map((group) => ({ value: group, label: group })));
}, [groups]);
function handleCreate(group) {
const newOption = { value: group, label: group };
setValue(newOption);
setOptions((prev) => [...prev, newOption]);
}
function handleChange() {
onChange(value ? value.value : "");
}
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{ overflow: "visible" }}
>
<Box onSubmit={handleChange} sx={{ width: "300px" }}>
<Label py={2}>Select or add a group</Label>
<Select
creatable
options={options}
value={value}
onChange={setValue}
onCreateOption={handleCreate}
placeholder=""
/>
<Flex py={2}>
<Button sx={{ flexGrow: 1 }} onClick={handleChange}>
Save
</Button>
</Flex>
</Box>
</Modal>
);
}
export default EditGroupModal;

View File

@ -4,6 +4,7 @@ import shortid from "shortid";
import Fuse from "fuse.js";
import EditMapModal from "./EditMapModal";
import EditGroupModal from "./EditGroupModal";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
@ -17,6 +18,7 @@ import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
import { resizeImage } from "../helpers/image";
import { groupBy } from "../helpers/shared";
const defaultMapSize = 22;
const defaultMapProps = {
@ -26,6 +28,7 @@ const defaultMapProps = {
showGrid: false,
snapToGrid: true,
quality: "original",
group: "",
};
const mapResolutions = [
@ -53,19 +56,29 @@ function SelectMapModal({
updateMap,
} = useContext(MapDataContext);
/**
* Search
*/
const [filteredMaps, setFilteredMaps] = useState([]);
const [filteredMapScores, setFilteredMapScores] = useState({});
const [fuse, setFuse] = useState();
const [search, setSearch] = useState("");
// Update search index when maps change
useEffect(() => {
setFuse(new Fuse(ownedMaps, { keys: ["name"] }));
setFuse(
new Fuse(ownedMaps, { keys: ["name", "group"], includeScore: true })
);
}, [ownedMaps]);
// Perform search when search changes
useEffect(() => {
if (search) {
setFilteredMaps(fuse.search(search).map((result) => result.item));
const query = fuse.search(search);
setFilteredMaps(query.map((result) => result.item));
setFilteredMapScores(
query.reduce((acc, value) => ({ ...acc, [value.item.id]: value.score }))
);
}
}, [search, ownedMaps, fuse]);
@ -73,21 +86,50 @@ function SelectMapModal({
setSearch(event.target.value);
}
const [imageLoading, setImageLoading] = useState(false);
/**
* Group
*/
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
// The map selected in the modal
const [selectedMapIds, setSelectedMapIds] = useState([]);
async function handleMapsGroup(group) {
setIsGroupModalOpen(false);
for (let id of selectedMapIds) {
await updateMap(id, { group });
}
}
const selectedMaps = ownedMaps.filter((map) =>
selectedMapIds.includes(map.id)
);
const selectedMapStates = mapStates.filter((state) =>
selectedMapIds.includes(state.mapId)
);
const mapsByGroup = groupBy(search ? filteredMaps : ownedMaps, "group");
// Get the groups of the maps sorting by the average score if we're filtering or the alphabetical order
// with "" at the start and "default" at the end if not
let mapGroups = Object.keys(mapsByGroup);
if (search) {
mapGroups.sort((a, b) => {
const aScore = mapsByGroup[a].reduce(
(acc, map) => (acc + filteredMapScores[map.id]) / 2
);
const bScore = mapsByGroup[b].reduce(
(acc, map) => (acc + filteredMapScores[map.id]) / 2
);
return aScore - bScore;
});
} else {
mapGroups.sort((a, b) => {
if (a === "" || b === "default") {
return -1;
}
if (b === "" || a === "default") {
return 1;
}
return a.localeCompare(b);
});
}
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
/**
* Image Upload
*/
const fileInputRef = useRef();
const [imageLoading, setImageLoading] = useState(false);
async function handleImagesUpload(files) {
for (let file of files) {
@ -193,6 +235,20 @@ function SelectMapModal({
}
}
/**
* Map Controls
*/
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// The map selected in the modal
const [selectedMapIds, setSelectedMapIds] = useState([]);
const selectedMaps = ownedMaps.filter((map) =>
selectedMapIds.includes(map.id)
);
const selectedMapStates = mapStates.filter((state) =>
selectedMapIds.includes(state.mapId)
);
async function handleMapAdd(map) {
await addMap(map);
setSelectedMapIds([map.id]);
@ -207,6 +263,16 @@ function SelectMapModal({
}
}
async function handleMapsReset() {
for (let id of selectedMapIds) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === id) {
onMapStateChange(newState);
}
}
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
@ -226,8 +292,12 @@ function SelectMapModal({
});
break;
case "range":
// Use filtered maps if we have searched
const maps = search ? filteredMaps : ownedMaps;
// Create maps array
let maps = mapGroups.reduce(
(acc, group) => [...acc, ...mapsByGroup[group]],
[]
);
// Add all items inbetween the previous selected map and the current selected
if (selectedMapIds.length > 0) {
const mapIndex = maps.findIndex((m) => m.id === map.id);
@ -265,15 +335,9 @@ function SelectMapModal({
}
}
async function handleMapsReset() {
for (let id of selectedMapIds) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === id) {
onMapStateChange(newState);
}
}
}
/**
* Modal Controls
*/
async function handleClose() {
onDone();
@ -294,6 +358,9 @@ function SelectMapModal({
onDone();
}
/**
* Shortcuts
*/
function handleKeyDown({ key }) {
if (key === "Shift") {
setSelectMode("range");
@ -338,7 +405,8 @@ function SelectMapModal({
Select or import a map
</Label>
<MapTiles
maps={search ? filteredMaps : ownedMaps}
maps={mapsByGroup}
groups={mapGroups}
onMapAdd={openImageDialog}
onMapEdit={() => setIsEditModalOpen(true)}
onMapsReset={handleMapsReset}
@ -351,6 +419,7 @@ function SelectMapModal({
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onMapsGroup={() => setIsGroupModalOpen(true)}
/>
<Button
variant="primary"
@ -369,6 +438,21 @@ function SelectMapModal({
map={selectedMaps.length === 1 && selectedMaps[0]}
mapState={selectedMapStates.length === 1 && selectedMapStates[0]}
/>
<EditGroupModal
isOpen={isGroupModalOpen}
onChange={handleMapsGroup}
groups={mapGroups.filter(
(group) => group !== "" && group !== "default"
)}
onRequestClose={() => setIsGroupModalOpen(false)}
// Select the default group by testing whether all selected maps are the same
defaultGroup={
selectedMaps.length > 0 &&
selectedMaps
.map((map) => map.group)
.reduce((prev, curr) => (prev === curr ? curr : undefined))
}
/>
</Modal>
);
}