diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js
index 0b18ee8..2abfb75 100644
--- a/src/components/map/MapTiles.js
+++ b/src/components/map/MapTiles.js
@@ -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 (
+
+ );
+ }
+
+ const multipleSelected = selectedMaps.length > 1;
+
return (
onMapSelect()}
>
- {maps.map((map) => {
- const isSelected = selectedMaps.includes(map);
- return (
-
- );
- })}
+ {/* Render ungrouped maps, grouped maps then default maps */}
+ {groups.map((group) => (
+
+
+ {maps[group].map(mapToTile)}
+
+ ))}
{databaseStatus === "disabled" && (
@@ -176,16 +191,24 @@ function MapTiles({
/>
onMapsGroup()}
+ disabled={hasSelectedDefaultMap}
+ >
+
+
+ onMapsReset()}
disabled={!hasMapState}
>
onMapsRemove()}
disabled={hasSelectedDefaultMap}
>
diff --git a/src/contexts/MapDataContext.js b/src/contexts/MapDataContext.js
index 9576dfe..fe25424 100644
--- a/src/contexts/MapDataContext.js
+++ b/src/contexts/MapDataContext.js
@@ -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);
diff --git a/src/database.js b/src/database.js
index 26d82ce..91be7af 100644
--- a/src/database.js
+++ b/src/database.js
@@ -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
diff --git a/src/icons/GroupIcon.js b/src/icons/GroupIcon.js
new file mode 100644
index 0000000..1285cfb
--- /dev/null
+++ b/src/icons/GroupIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function GroupIcon() {
+ return (
+
+ );
+}
+
+export default GroupIcon;
diff --git a/src/modals/EditGroupModal.js b/src/modals/EditGroupModal.js
new file mode 100644
index 0000000..a990bd0
--- /dev/null
+++ b/src/modals/EditGroupModal.js
@@ -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 (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default EditGroupModal;
diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js
index f242fbb..7b6897f 100644
--- a/src/modals/SelectMapModal.js
+++ b/src/modals/SelectMapModal.js
@@ -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
setIsEditModalOpen(true)}
onMapsReset={handleMapsReset}
@@ -351,6 +419,7 @@ function SelectMapModal({
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
+ onMapsGroup={() => setIsGroupModalOpen(true)}
/>
+ 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))
+ }
+ />
);
}