Merge branch 'master' into typescript
This commit is contained in:
@@ -1,59 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Flex, Label } from "theme-ui";
|
||||
import { useState } from "react";
|
||||
import { Button, Flex, Label, useThemeUI } from "theme-ui";
|
||||
import SimpleBar from "simplebar-react";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import MapSettings from "../components/map/MapSettings";
|
||||
import MapEditor from "../components/map/MapEditor";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
|
||||
import { useMapData } from "../contexts/MapDataContext";
|
||||
|
||||
import { isEmpty } from "../helpers/shared";
|
||||
import { getGridDefaultInset } from "../helpers/grid";
|
||||
|
||||
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
||||
import { MapState } from "../components/map/Map";
|
||||
import { Map, MapState } from "../components/map/Map";
|
||||
|
||||
type EditMapProps = {
|
||||
isOpen: boolean,
|
||||
onDone: any,
|
||||
mapId: string
|
||||
}
|
||||
isOpen: boolean;
|
||||
onDone: () => void;
|
||||
map: Map;
|
||||
mapState: MapState;
|
||||
onUpdateMap: (id: string, update: Partial<Map>) => void;
|
||||
onUpdateMapState: (id: string, update: Partial<MapState>) => void;
|
||||
};
|
||||
|
||||
function EditMapModal({ isOpen, onDone, mapId }: EditMapProps) {
|
||||
const {
|
||||
updateMap,
|
||||
updateMapState,
|
||||
getMap,
|
||||
getMapFromDB,
|
||||
getMapStateFromDB,
|
||||
} = useMapData();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [map, setMap] = useState<any>();
|
||||
const [mapState, setMapState] = useState<MapState>();
|
||||
// Load full map when modal is opened
|
||||
useEffect(() => {
|
||||
async function loadMap() {
|
||||
setIsLoading(true);
|
||||
let loadingMap = getMap(mapId);
|
||||
// Ensure file is loaded for map
|
||||
if (loadingMap?.type === "file" && !loadingMap?.file) {
|
||||
loadingMap = await getMapFromDB(mapId);
|
||||
}
|
||||
const mapState = await getMapStateFromDB(mapId);
|
||||
setMap(loadingMap);
|
||||
setMapState(mapState as MapState);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (isOpen && mapId) {
|
||||
loadMap();
|
||||
} else {
|
||||
setMap(undefined);
|
||||
setMapState(undefined);
|
||||
}
|
||||
}, [isOpen, mapId, getMapFromDB, getMapStateFromDB, getMap]);
|
||||
function EditMapModal({
|
||||
isOpen,
|
||||
onDone,
|
||||
map,
|
||||
mapState,
|
||||
onUpdateMap,
|
||||
onUpdateMapState,
|
||||
}: EditMapProps) {
|
||||
const { theme } = useThemeUI();
|
||||
|
||||
function handleClose() {
|
||||
setMapSettingChanges({});
|
||||
@@ -119,8 +95,8 @@ function EditMapModal({ isOpen, onDone, mapId }: EditMapProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
await updateMap(map.id, mapSettingChanges);
|
||||
await updateMapState(map.id, mapStateSettingChanges);
|
||||
await onUpdateMap(map.id, mapSettingChanges);
|
||||
await onUpdateMapState(map.id, mapStateSettingChanges);
|
||||
|
||||
setMapSettingChanges({});
|
||||
setMapStateSettingChanges({});
|
||||
@@ -136,50 +112,58 @@ function EditMapModal({ isOpen, onDone, mapId }: EditMapProps) {
|
||||
...mapStateSettingChanges,
|
||||
};
|
||||
|
||||
const [showMoreSettings, setShowMoreSettings] = useState(true);
|
||||
|
||||
const layout = useResponsiveLayout();
|
||||
|
||||
if (!map) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
style={{ content: {maxWidth: layout.modalSize, width: "calc(100% - 16px)"} }}
|
||||
style={{
|
||||
content: {
|
||||
maxWidth: layout.modalSize,
|
||||
width: "calc(100% - 16px)",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
<Label pt={2} pb={1} px={3}>
|
||||
Edit map
|
||||
</Label>
|
||||
{isLoading || !map ? (
|
||||
<Flex
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: layout.screenSize === "large" ? "500px" : "300px",
|
||||
position: "relative",
|
||||
}}
|
||||
bg="muted"
|
||||
>
|
||||
<LoadingOverlay />
|
||||
</Flex>
|
||||
) : (
|
||||
<SimpleBar
|
||||
style={{
|
||||
minHeight: 0,
|
||||
padding: "16px",
|
||||
backgroundColor: theme.colors.muted,
|
||||
margin: "0 8px",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<MapEditor
|
||||
map={selectedMapWithChanges}
|
||||
onSettingsChange={handleMapSettingsChange}
|
||||
/>
|
||||
)}
|
||||
<MapSettings
|
||||
map={selectedMapWithChanges}
|
||||
mapState={selectedMapStateWithChanges}
|
||||
onSettingsChange={handleMapSettingsChange}
|
||||
onStateSettingsChange={handleMapStateSettingsChange}
|
||||
showMore={showMoreSettings}
|
||||
onShowMoreChange={setShowMoreSettings}
|
||||
/>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<MapSettings
|
||||
map={selectedMapWithChanges}
|
||||
mapState={selectedMapStateWithChanges}
|
||||
onSettingsChange={handleMapSettingsChange}
|
||||
onStateSettingsChange={handleMapStateSettingsChange}
|
||||
/>
|
||||
</SimpleBar>
|
||||
<Button m={3} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,30 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Flex, Label } from "theme-ui";
|
||||
import { useState } from "react";
|
||||
import { Button, Flex, Label, useThemeUI } from "theme-ui";
|
||||
import SimpleBar from "simplebar-react";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import TokenSettings from "../components/token/TokenSettings";
|
||||
import TokenPreview from "../components/token/TokenPreview";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
|
||||
import { useTokenData } from "../contexts/TokenDataContext";
|
||||
|
||||
import { isEmpty } from "../helpers/shared";
|
||||
|
||||
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
||||
import { Token } from "../tokens";
|
||||
|
||||
type EditModalProps = {
|
||||
isOpen: boolean,
|
||||
onDone: () => void,
|
||||
tokenId: string,
|
||||
type EditModalProps = {
|
||||
isOpen: boolean;
|
||||
onDone: () => void;
|
||||
token: Token;
|
||||
onUpdateToken: (id: string, update: Partial<Token>) => void;
|
||||
};
|
||||
|
||||
function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
|
||||
const { updateToken, getTokenFromDB } = useTokenData();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [token, setToken] = useState<Token>();
|
||||
useEffect(() => {
|
||||
async function loadToken() {
|
||||
setIsLoading(true);
|
||||
setToken(await getTokenFromDB(tokenId));
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (isOpen && tokenId) {
|
||||
loadToken();
|
||||
} else {
|
||||
setToken(undefined);
|
||||
}
|
||||
}, [isOpen, tokenId, getTokenFromDB]);
|
||||
function EditTokenModal({
|
||||
isOpen,
|
||||
onDone,
|
||||
token,
|
||||
onUpdateToken,
|
||||
}: EditModalProps) {
|
||||
const { theme } = useThemeUI();
|
||||
|
||||
function handleClose() {
|
||||
setTokenSettingChanges({});
|
||||
@@ -48,9 +36,11 @@ function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
|
||||
onDone();
|
||||
}
|
||||
|
||||
const [tokenSettingChanges, setTokenSettingChanges] = useState<any>({});
|
||||
const [tokenSettingChanges, setTokenSettingChanges] = useState<
|
||||
Partial<Token>
|
||||
>({});
|
||||
|
||||
function handleTokenSettingsChange(key: any, value: any) {
|
||||
function handleTokenSettingsChange(key: string, value: Pick<Token, any>) {
|
||||
setTokenSettingChanges((prevChanges: any) => ({
|
||||
...prevChanges,
|
||||
[key]: value,
|
||||
@@ -65,7 +55,7 @@ function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
|
||||
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
|
||||
}
|
||||
|
||||
await updateToken(token.id, verifiedChanges);
|
||||
await onUpdateToken(token.id, verifiedChanges);
|
||||
setTokenSettingChanges({});
|
||||
}
|
||||
}
|
||||
@@ -77,41 +67,51 @@ function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
|
||||
|
||||
const layout = useResponsiveLayout();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
style={{
|
||||
content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" },
|
||||
content: {
|
||||
maxWidth: layout.modalSize,
|
||||
width: "calc(100% - 16px)",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
<Label pt={2} pb={1} px={3}>
|
||||
Edit token
|
||||
</Label>
|
||||
{isLoading || !token ? (
|
||||
<Flex
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: layout.screenSize === "large" ? "500px" : "300px",
|
||||
position: "relative",
|
||||
}}
|
||||
bg="muted"
|
||||
>
|
||||
<LoadingOverlay />
|
||||
</Flex>
|
||||
) : (
|
||||
<SimpleBar
|
||||
style={{
|
||||
minHeight: 0,
|
||||
padding: "16px",
|
||||
backgroundColor: theme.colors.muted,
|
||||
margin: "0 8px",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<TokenPreview token={selectedTokenWithChanges} />
|
||||
)}
|
||||
<TokenSettings
|
||||
token={selectedTokenWithChanges}
|
||||
onSettingsChange={handleTokenSettingsChange}
|
||||
/>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<TokenSettings
|
||||
token={selectedTokenWithChanges}
|
||||
onSettingsChange={handleTokenSettingsChange}
|
||||
/>
|
||||
</SimpleBar>
|
||||
<Button m={3} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
51
src/modals/GroupNameModal.js
Normal file
51
src/modals/GroupNameModal.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Box, Input, Button, Label, Flex } from "theme-ui";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
function GroupNameModal({ isOpen, name, onSubmit, onRequestClose }) {
|
||||
const [tmpName, setTempName] = useState(name);
|
||||
|
||||
useEffect(() => {
|
||||
setTempName(name);
|
||||
}, [name]);
|
||||
|
||||
function handleChange(event) {
|
||||
setTempName(event.target.value);
|
||||
}
|
||||
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
onSubmit(tmpName);
|
||||
}
|
||||
|
||||
const inputRef = useRef();
|
||||
function focusInput() {
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
onAfterOpen={focusInput}
|
||||
>
|
||||
<Box as="form" onSubmit={handleSubmit}>
|
||||
<Label py={2} htmlFor="name">
|
||||
Group Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={tmpName}
|
||||
onChange={handleChange}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Flex py={2}>
|
||||
<Button sx={{ flexGrow: 1 }}>Save</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupNameModal;
|
||||
@@ -3,6 +3,7 @@ import { Box, Label, Text, Button, Flex } from "theme-ui";
|
||||
import { saveAs } from "file-saver";
|
||||
import * as Comlink from "comlink";
|
||||
import shortid from "shortid";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { useToasts } from "react-toast-notifications";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
@@ -10,19 +11,33 @@ import LoadingOverlay from "../components/LoadingOverlay";
|
||||
import LoadingBar from "../components/LoadingBar";
|
||||
import ErrorBanner from "../components/banner/ErrorBanner";
|
||||
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useUserId } from "../contexts/UserIdContext";
|
||||
import { useDatabase } from "../contexts/DatabaseContext";
|
||||
|
||||
import SelectDataModal from "./SelectDataModal";
|
||||
|
||||
import { getDatabase } from "../database";
|
||||
import { Map, MapState, TokenState } from "../components/map/Map";
|
||||
import { Token } from "../tokens";
|
||||
|
||||
const importDBName = "OwlbearRodeoImportDB";
|
||||
|
||||
function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: () => void}) {
|
||||
class MissingAssetError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "MissingAssetError";
|
||||
}
|
||||
}
|
||||
|
||||
function ImportExportModal({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onRequestClose: () => void;
|
||||
}) {
|
||||
const { worker } = useDatabase();
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
@@ -34,7 +49,7 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
const [showExportSelector, setShowExportSelector] = useState(false);
|
||||
|
||||
const { addToast } = useToasts();
|
||||
function addSuccessToast(message: string, maps: any, tokens: TokenState[]) {
|
||||
function addSuccessToast(message: string, maps: Map[], tokens: Token[]) {
|
||||
const mapText = `${maps.length} map${maps.length > 1 ? "s" : ""}`;
|
||||
const tokenText = `${tokens.length} token${tokens.length > 1 ? "s" : ""}`;
|
||||
if (maps.length > 0 && tokens.length > 0) {
|
||||
@@ -54,7 +69,13 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
|
||||
const loadingProgressRef = useRef(0);
|
||||
|
||||
function handleDBProgress({ completedRows, totalRows }: { completedRows: number, totalRows: number }) {
|
||||
function handleDBProgress({
|
||||
completedRows,
|
||||
totalRows,
|
||||
}: {
|
||||
completedRows: number;
|
||||
totalRows: number;
|
||||
}) {
|
||||
loadingProgressRef.current = completedRows / totalRows;
|
||||
}
|
||||
|
||||
@@ -81,6 +102,7 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(e);
|
||||
setError(e);
|
||||
}
|
||||
}
|
||||
@@ -122,7 +144,12 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
setShowImportSelector(false);
|
||||
}
|
||||
|
||||
async function handleImportSelectorConfirm(checkedMaps: any, checkedTokens: TokenState[]) {
|
||||
async function handleImportSelectorConfirm(
|
||||
checkedMaps: Map[],
|
||||
checkedTokens: Token[],
|
||||
checkedMapGroups: any[],
|
||||
checkedTokenGroups: any[]
|
||||
) {
|
||||
setIsLoading(true);
|
||||
backgroundTaskRunningRef.current = true;
|
||||
setShowImportSelector(false);
|
||||
@@ -132,25 +159,46 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
const db = getDatabase({});
|
||||
try {
|
||||
// Keep track of a mapping of old token ids to new ones to apply them to the map states
|
||||
let newTokenIds: {[id: string]: string} = {};
|
||||
let newTokenIds: Record<string, string> = {};
|
||||
// Mapping of old asset ids to new asset ids
|
||||
let newAssetIds: Record<string, string> = {};
|
||||
// Mapping of old maps ids to new map ids
|
||||
let newMapIds: Record<string, string> = {};
|
||||
|
||||
let newTokens: Token[] = [];
|
||||
if (checkedTokens.length > 0) {
|
||||
const tokenIds = checkedTokens.map((token) => token.id);
|
||||
const tokensToAdd: TokenState[] = await importDB.table("tokens").bulkGet(tokenIds);
|
||||
let newTokens: TokenState[] = [];
|
||||
const tokensToAdd = await importDB.table("tokens").bulkGet(tokenIds);
|
||||
for (let token of tokensToAdd) {
|
||||
const newId = shortid.generate();
|
||||
// Generate new ids
|
||||
const newId = uuid();
|
||||
newTokenIds[token.id] = newId;
|
||||
// Generate new id and change owner
|
||||
newTokens.push({ ...token, id: newId, owner: userId });
|
||||
|
||||
if (token.type === "default") {
|
||||
newTokens.push({ ...token, id: newId, owner: userId });
|
||||
} else {
|
||||
const newFileId = uuid();
|
||||
const newThumbnailId = uuid();
|
||||
newAssetIds[token.file] = newFileId;
|
||||
newAssetIds[token.thumbnail] = newThumbnailId;
|
||||
|
||||
// Change ids and owner
|
||||
newTokens.push({
|
||||
...token,
|
||||
id: newId,
|
||||
owner: userId,
|
||||
file: newFileId,
|
||||
thumbnail: newThumbnailId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.table("tokens").bulkAdd(newTokens);
|
||||
}
|
||||
|
||||
let newMaps: Map[] = [];
|
||||
let newStates: MapState[] = [];
|
||||
if (checkedMaps.length > 0) {
|
||||
const mapIds = checkedMaps.map((map: any) => map.id);
|
||||
const mapsToAdd = await importDB.table("maps").bulkGet(mapIds);
|
||||
let newMaps = [];
|
||||
let newStates = [];
|
||||
for (let map of mapsToAdd) {
|
||||
let state: MapState = await importDB.table("states").get(map.id);
|
||||
// Apply new token ids to imported state
|
||||
@@ -159,19 +207,145 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
state.tokens[tokenState.id].tokenId =
|
||||
newTokenIds[tokenState.tokenId];
|
||||
}
|
||||
// Change token state file asset id
|
||||
if (tokenState.type === "file" && tokenState.file in newAssetIds) {
|
||||
state.tokens[tokenState.id].file = newAssetIds[tokenState.file];
|
||||
}
|
||||
// Change token state owner if owned by the user of the map
|
||||
if (tokenState.owner === map.owner) {
|
||||
state.tokens[tokenState.id].owner = userId;
|
||||
}
|
||||
}
|
||||
const newId = shortid.generate();
|
||||
// Generate new id and change owner
|
||||
newMaps.push({ ...map, id: newId, owner: userId });
|
||||
// Generate new ids
|
||||
const newId = uuid();
|
||||
newMapIds[map.id] = newId;
|
||||
|
||||
if (map.type === "default") {
|
||||
newMaps.push({ ...map, id: newId, owner: userId });
|
||||
} else {
|
||||
const newFileId = uuid();
|
||||
const newThumbnailId = uuid();
|
||||
newAssetIds[map.file] = newFileId;
|
||||
newAssetIds[map.thumbnail] = newThumbnailId;
|
||||
const newResolutionIds: Record<string, string> = {};
|
||||
for (let res of Object.keys(map.resolutions)) {
|
||||
newResolutionIds[res] = uuid();
|
||||
newAssetIds[map.resolutions[res]] = newResolutionIds[res];
|
||||
}
|
||||
// Change ids and owner
|
||||
newMaps.push({
|
||||
...map,
|
||||
id: newId,
|
||||
owner: userId,
|
||||
file: newFileId,
|
||||
thumbnail: newThumbnailId,
|
||||
resolutions: newResolutionIds,
|
||||
});
|
||||
}
|
||||
|
||||
newStates.push({ ...state, mapId: newId });
|
||||
}
|
||||
await db.table("maps").bulkAdd(newMaps);
|
||||
await db.table("states").bulkAdd(newStates);
|
||||
}
|
||||
|
||||
// Add assets with new ids
|
||||
const assetsToAdd = await importDB
|
||||
.table("assets")
|
||||
.bulkGet(Object.keys(newAssetIds));
|
||||
let newAssets: any[] = [];
|
||||
for (let asset of assetsToAdd) {
|
||||
if (asset) {
|
||||
newAssets.push({
|
||||
...asset,
|
||||
id: newAssetIds[asset.id],
|
||||
owner: userId,
|
||||
});
|
||||
} else {
|
||||
throw new MissingAssetError("Import missing assets");
|
||||
}
|
||||
}
|
||||
|
||||
// Add map groups with new ids
|
||||
let newMapGroups: any[] = [];
|
||||
if (checkedMapGroups.length > 0) {
|
||||
for (let group of checkedMapGroups) {
|
||||
if (group.type === "item") {
|
||||
newMapGroups.push({ ...group, id: newMapIds[group.id] });
|
||||
} else {
|
||||
newMapGroups.push({
|
||||
...group,
|
||||
id: uuid(),
|
||||
items: group.items.map((item) => ({
|
||||
...item,
|
||||
id: newMapIds[item.id],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add token groups with new ids
|
||||
let newTokenGroups: any[] = [];
|
||||
if (checkedTokenGroups.length > 0) {
|
||||
for (let group of checkedTokenGroups) {
|
||||
if (group.type === "item") {
|
||||
newTokenGroups.push({ ...group, id: newTokenIds[group.id] });
|
||||
} else {
|
||||
newTokenGroups.push({
|
||||
...group,
|
||||
id: uuid(),
|
||||
items: group.items.map((item: any) => ({
|
||||
...item,
|
||||
id: newTokenIds[item.id],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.transaction(
|
||||
"rw",
|
||||
[
|
||||
db.table("tokens"),
|
||||
db.table("maps"),
|
||||
db.table("states"),
|
||||
db.table("assets"),
|
||||
db.table("groups"),
|
||||
],
|
||||
async () => {
|
||||
if (newTokens.length > 0) {
|
||||
await db.table("tokens").bulkAdd(newTokens);
|
||||
}
|
||||
if (newMaps.length > 0) {
|
||||
await db.table("maps").bulkAdd(newMaps);
|
||||
}
|
||||
if (newStates.length > 0) {
|
||||
await db.table("states").bulkAdd(newStates);
|
||||
}
|
||||
if (newAssets.length > 0) {
|
||||
await db.table("assets").bulkAdd(newAssets);
|
||||
}
|
||||
if (newMapGroups.length > 0) {
|
||||
const mapGroup = await db.table("groups").get("maps");
|
||||
await db
|
||||
.table("groups")
|
||||
.update("maps", { items: [...newMapGroups, ...mapGroup.items] });
|
||||
}
|
||||
if (newTokenGroups.length > 0) {
|
||||
const tokenGroup = await db.table("groups").get("tokens");
|
||||
await db.table("groups").update("tokens", {
|
||||
items: [...newTokenGroups, ...tokenGroup.items],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
addSuccessToast("Imported", checkedMaps, checkedTokens);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(new Error("Unable to import data"));
|
||||
if (e instanceof MissingAssetError) {
|
||||
setError(e);
|
||||
} else {
|
||||
setError(new Error("Unable to import data"));
|
||||
}
|
||||
}
|
||||
await importDB.delete();
|
||||
importDB.close();
|
||||
@@ -180,25 +354,23 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
backgroundTaskRunningRef.current = false;
|
||||
}
|
||||
|
||||
function exportSelectorFilter(table: any, value: Map | TokenState) {
|
||||
// Only show owned maps and tokens
|
||||
if (table === "maps" || table === "tokens") {
|
||||
if (value.owner === userId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Allow all states so tokens can be checked against maps
|
||||
if (table === "states") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
function exportSelectorFilter(table: string) {
|
||||
return (
|
||||
table === "maps" ||
|
||||
table === "tokens" ||
|
||||
table === "states" ||
|
||||
table === "groups"
|
||||
);
|
||||
}
|
||||
|
||||
async function handleExportSelectorClose() {
|
||||
setShowExportSelector(false);
|
||||
}
|
||||
|
||||
async function handleExportSelectorConfirm(checkedMaps: Map[], checkedTokens: TokenState[]) {
|
||||
async function handleExportSelectorConfirm(
|
||||
checkedMaps: Map[],
|
||||
checkedTokens: TokenState[]
|
||||
) {
|
||||
setShowExportSelector(false);
|
||||
setIsLoading(true);
|
||||
backgroundTaskRunningRef.current = true;
|
||||
@@ -216,6 +388,7 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
saveAs(blob, `${shortid.generate()}.owlbear`);
|
||||
addSuccessToast("Exported", checkedMaps, checkedTokens);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
@@ -239,7 +412,9 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
Select import or export then select the data you wish to use
|
||||
</Text>
|
||||
<input
|
||||
onChange={(event) => event.target.files && handleImportDatabase(event.target.files[0])}
|
||||
onChange={(event) =>
|
||||
event.target.files && handleImportDatabase(event.target.files[0])
|
||||
}
|
||||
type="file"
|
||||
accept=".owlbear"
|
||||
style={{ display: "none" }}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { Box, Label, Flex, Button, Text, Checkbox, Divider } from "theme-ui";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Box, Label, Flex, Button, Text, Checkbox } from "theme-ui";
|
||||
import SimpleBar from "simplebar-react";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
import Divider from "../components/Divider";
|
||||
|
||||
import { getDatabase } from "../database";
|
||||
import { Props } from "react-modal";
|
||||
|
||||
type SelectDataProps = Props & {
|
||||
onConfirm: any,
|
||||
confirmText: string,
|
||||
label: string,
|
||||
databaseName: string,
|
||||
filter: any,
|
||||
}
|
||||
type SelectDataProps = {
|
||||
isOpen: boolean;
|
||||
onRequestClose: () => void;
|
||||
onConfirm: any;
|
||||
confirmText: string;
|
||||
label: string;
|
||||
databaseName: string;
|
||||
filter: any;
|
||||
};
|
||||
|
||||
type SelectData = {
|
||||
name: string;
|
||||
id: string;
|
||||
type: "default" | "file";
|
||||
checked: boolean;
|
||||
};
|
||||
|
||||
function SelectDataModal({
|
||||
isOpen,
|
||||
@@ -25,9 +34,13 @@ function SelectDataModal({
|
||||
databaseName,
|
||||
filter,
|
||||
}: SelectDataProps) {
|
||||
const [maps, setMaps] = useState<any>({});
|
||||
const [tokensByMap, setTokensByMap] = useState<any>({});
|
||||
const [tokens, setTokens] = useState<any>({});
|
||||
const [maps, setMaps] = useState<Record<string, SelectData>>({});
|
||||
const [mapGroups, setMapGroups] = useState<any[]>([]);
|
||||
const [tokensByMap, setTokensByMap] = useState<Record<string, Set<string>>>(
|
||||
{}
|
||||
);
|
||||
const [tokens, setTokens] = useState<Record<string, SelectData>>({});
|
||||
const [tokenGroups, setTokenGroups] = useState<any[]>([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const hasMaps = Object.values(maps).length > 0;
|
||||
@@ -38,14 +51,19 @@ function SelectDataModal({
|
||||
if (isOpen && databaseName) {
|
||||
setIsLoading(true);
|
||||
const db = getDatabase({ addons: [] }, databaseName);
|
||||
let loadedMaps: any = [];
|
||||
let loadedTokensByMap: any = {};
|
||||
let loadedTokens: any = [];
|
||||
let loadedMaps: Record<string, SelectData> = {};
|
||||
let loadedTokensByMap: Record<string, Set<string>> = {};
|
||||
let loadedTokens: Record<string, SelectData> = {};
|
||||
await db
|
||||
.table("maps")
|
||||
.filter((map) => filter("maps", map, map.id))
|
||||
.each((map) => {
|
||||
loadedMaps[map.id] = { name: map.name, id: map.id, checked: true };
|
||||
loadedMaps[map.id] = {
|
||||
name: map.name,
|
||||
id: map.id,
|
||||
type: map.type,
|
||||
checked: true,
|
||||
};
|
||||
});
|
||||
await db
|
||||
.table("states")
|
||||
@@ -65,17 +83,27 @@ function SelectDataModal({
|
||||
loadedTokens[token.id] = {
|
||||
name: token.name,
|
||||
id: token.id,
|
||||
type: token.type,
|
||||
checked: true,
|
||||
};
|
||||
});
|
||||
|
||||
const mapGroup = await db.table("groups").get("maps");
|
||||
const tokenGroup = await db.table("groups").get("tokens");
|
||||
|
||||
db.close();
|
||||
setMaps(loadedMaps);
|
||||
setMapGroups(mapGroup.items);
|
||||
setTokensByMap(loadedTokensByMap);
|
||||
setTokenGroups(tokenGroup.items);
|
||||
setTokens(loadedTokens);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setMaps({});
|
||||
setTokens({});
|
||||
setTokenGroups([]);
|
||||
setMapGroups([]);
|
||||
setTokensByMap({});
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
@@ -101,7 +129,7 @@ function SelectDataModal({
|
||||
setTokens((prevTokens: any) => {
|
||||
let newTokens = { ...prevTokens };
|
||||
for (let id in newTokens) {
|
||||
if (id in tokensUsed) {
|
||||
if (id in tokensUsed && newTokens[id].type !== "default") {
|
||||
newTokens[id].checked = true;
|
||||
}
|
||||
}
|
||||
@@ -109,17 +137,45 @@ function SelectDataModal({
|
||||
});
|
||||
}, [maps, tokensByMap]);
|
||||
|
||||
function handleConfirm() {
|
||||
let checkedMaps = Object.values(maps).filter((map: any) => map.checked);
|
||||
let checkedTokens = Object.values(tokens).filter((token: any) => token.checked);
|
||||
onConfirm(checkedMaps, checkedTokens);
|
||||
function getCheckedGroups(groups: any[], data: Record<string, SelectData>) {
|
||||
let checkedGroups = [];
|
||||
for (let group of groups) {
|
||||
if (group.type === "item") {
|
||||
if (data[group.id] && data[group.id].checked) {
|
||||
checkedGroups.push(group);
|
||||
}
|
||||
} else {
|
||||
let items = [];
|
||||
for (let item of group.items) {
|
||||
if (data[item.id] && data[item.id].checked) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
if (items.length > 0) {
|
||||
checkedGroups.push({ ...group, items });
|
||||
}
|
||||
}
|
||||
}
|
||||
return checkedGroups;
|
||||
}
|
||||
|
||||
function handleSelectMapsChanged(event: ChangeEvent<HTMLInputElement>) {
|
||||
setMaps((prevMaps: any) => {
|
||||
function handleConfirm() {
|
||||
let checkedMaps = Object.values(maps).filter((map) => map.checked);
|
||||
let checkedTokens = Object.values(tokens).filter((token) => token.checked);
|
||||
let checkedMapGroups = getCheckedGroups(mapGroups, maps);
|
||||
let checkedTokenGroups = getCheckedGroups(tokenGroups, tokens);
|
||||
|
||||
onConfirm(checkedMaps, checkedTokens, checkedMapGroups, checkedTokenGroups);
|
||||
}
|
||||
|
||||
function handleMapsChanged(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
maps: SelectData[]
|
||||
) {
|
||||
setMaps((prevMaps) => {
|
||||
let newMaps = { ...prevMaps };
|
||||
for (let id in newMaps) {
|
||||
newMaps[id].checked = event.target.checked;
|
||||
for (let map of maps) {
|
||||
newMaps[map.id].checked = event.target.checked;
|
||||
}
|
||||
return newMaps;
|
||||
});
|
||||
@@ -127,26 +183,17 @@ function SelectDataModal({
|
||||
if (!event.target.checked && !tokensSelectChecked) {
|
||||
setTokens((prevTokens: any) => {
|
||||
let newTokens = { ...prevTokens };
|
||||
let tempUsedCount = { ...tokenUsedCount };
|
||||
for (let id in newTokens) {
|
||||
newTokens[id].checked = false;
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapChange(event: ChangeEvent<HTMLInputElement>, map: any) {
|
||||
setMaps((prevMaps: any) => ({
|
||||
...prevMaps,
|
||||
[map.id]: { ...map, checked: event.target.checked },
|
||||
}));
|
||||
// If all token select is unchecked then ensure tokens assosiated to this map are unchecked
|
||||
if (!event.target.checked && !tokensSelectChecked) {
|
||||
setTokens((prevTokens: any) => {
|
||||
let newTokens = { ...prevTokens };
|
||||
for (let id in newTokens) {
|
||||
if (tokensByMap[map.id].has(id) && tokenUsedCount[id] === 1) {
|
||||
newTokens[id].checked = false;
|
||||
for (let map of maps) {
|
||||
if (tokensByMap[map.id].has(id)) {
|
||||
if (tempUsedCount[id] > 1) {
|
||||
tempUsedCount[id] -= 1;
|
||||
} else if (tempUsedCount[id] === 1) {
|
||||
tempUsedCount[id] = 0;
|
||||
newTokens[id].checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newTokens;
|
||||
@@ -154,36 +201,164 @@ function SelectDataModal({
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectTokensChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setTokens((prevTokens: any) => {
|
||||
function handleTokensChanged(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
tokens: SelectData[]
|
||||
) {
|
||||
setTokens((prevTokens) => {
|
||||
let newTokens = { ...prevTokens };
|
||||
for (let id in newTokens) {
|
||||
if (!(id in tokenUsedCount)) {
|
||||
newTokens[id].checked = event.target.checked;
|
||||
for (let token of tokens) {
|
||||
if (!(token.id in tokenUsedCount) || token.type === "default") {
|
||||
newTokens[token.id].checked = event.target.checked;
|
||||
}
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
}
|
||||
|
||||
function handleTokenChange(event: ChangeEvent<HTMLInputElement>, token: any) {
|
||||
setTokens((prevTokens: any) => ({
|
||||
...prevTokens,
|
||||
[token.id]: { ...token, checked: event.target.checked },
|
||||
}));
|
||||
}
|
||||
|
||||
// Some tokens are checked not by maps or all tokens are checked by maps
|
||||
const tokensSelectChecked =
|
||||
Object.values(tokens).some(
|
||||
(token: any) => !(token.id in tokenUsedCount) && token.checked
|
||||
) || Object.values(tokens).every((token: any) => token.id in tokenUsedCount);
|
||||
) ||
|
||||
Object.values(tokens).every((token: any) => token.id in tokenUsedCount);
|
||||
|
||||
function renderGroupContainer(
|
||||
group: any,
|
||||
checked: boolean,
|
||||
renderItem: (group: any) => React.ReactNode,
|
||||
onGroupChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
group: any
|
||||
) => void
|
||||
) {
|
||||
return (
|
||||
<Flex
|
||||
ml={4}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
borderRadius: "4px",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
key={group.id}
|
||||
>
|
||||
<Label my={1} sx={{ fontFamily: "body2" }}>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onGroupChange(e, group)
|
||||
}
|
||||
/>
|
||||
{group.name}
|
||||
</Label>
|
||||
<Flex
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{group.items.map(renderItem)}
|
||||
<Box
|
||||
sx={{ position: "absolute", left: "2px", top: 0, height: "100%" }}
|
||||
>
|
||||
<Divider vertical fill />
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMapGroup(group: any) {
|
||||
if (group.type === "item") {
|
||||
const map = maps[group.id];
|
||||
if (map) {
|
||||
return (
|
||||
<Label key={map.id} my={1} pl={4} sx={{ fontFamily: "body2" }}>
|
||||
<Checkbox
|
||||
checked={map.checked}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleMapsChanged(e, [map])
|
||||
}
|
||||
/>
|
||||
{map.name}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (group.items.some((item: any) => item.id in maps)) {
|
||||
return renderGroupContainer(
|
||||
group,
|
||||
group.items.some((item: any) => maps[item.id]?.checked),
|
||||
renderMapGroup,
|
||||
(e, group) =>
|
||||
handleMapsChanged(
|
||||
e,
|
||||
group.items
|
||||
.filter((group: any) => group.id in maps)
|
||||
.map((group: any) => maps[group.id])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTokenGroup(group: any) {
|
||||
if (group.type === "item") {
|
||||
const token = tokens[group.id];
|
||||
if (token) {
|
||||
return (
|
||||
<Box pl={4} my={1} key={token.id}>
|
||||
<Label sx={{ fontFamily: "body2" }}>
|
||||
<Checkbox
|
||||
checked={token.checked}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleTokensChanged(e, [token])
|
||||
}
|
||||
disabled={
|
||||
token.type !== "default" && token.id in tokenUsedCount
|
||||
}
|
||||
/>
|
||||
{token.name}
|
||||
</Label>
|
||||
{token.id in tokenUsedCount && token.type !== "default" && (
|
||||
<Text as="p" variant="caption" ml={4}>
|
||||
Token used in {tokenUsedCount[token.id]} selected map
|
||||
{tokenUsedCount[token.id] > 1 && "s"}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (group.items.some((item: any) => item.id in tokens)) {
|
||||
const checked =
|
||||
group.items.some(
|
||||
(item: any) =>
|
||||
!(item.id in tokenUsedCount) && tokens[item.id]?.checked
|
||||
) || group.items.every((item: any) => item.id in tokenUsedCount);
|
||||
return renderGroupContainer(
|
||||
group,
|
||||
checked,
|
||||
renderTokenGroup,
|
||||
(e, group) =>
|
||||
handleTokensChanged(
|
||||
e,
|
||||
group.items
|
||||
.filter((group: any) => group.id in tokens)
|
||||
.map((group: any) => tokens[group.id])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
style={{ content: {maxWidth: "450px", width: "100%"} }}
|
||||
style={{ content: { maxWidth: "450px", width: "100%" } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -214,56 +389,30 @@ function SelectDataModal({
|
||||
<Flex>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={Object.values(maps).some((map: any) => map.checked)}
|
||||
onChange={handleSelectMapsChanged}
|
||||
checked={Object.values(maps).some((map) => map.checked)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleMapsChanged(e, Object.values(maps))
|
||||
}
|
||||
/>
|
||||
Maps
|
||||
</Label>
|
||||
</Flex>
|
||||
{Object.values(maps).map((map: any) => (
|
||||
<Label
|
||||
key={map.id}
|
||||
my={1}
|
||||
pl={4}
|
||||
sx={{ fontFamily: "body2" }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={map.checked}
|
||||
onChange={(e) => handleMapChange(e, map)}
|
||||
/>
|
||||
{map.name}
|
||||
</Label>
|
||||
))}
|
||||
{mapGroups.map(renderMapGroup)}
|
||||
</>
|
||||
)}
|
||||
{hasMaps && hasTokens && <Divider bg="text" />}
|
||||
{hasMaps && hasTokens && <Divider fill />}
|
||||
{hasTokens && (
|
||||
<>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={tokensSelectChecked}
|
||||
onChange={handleSelectTokensChange}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleTokensChanged(e, Object.values(tokens))
|
||||
}
|
||||
/>
|
||||
Tokens
|
||||
</Label>
|
||||
{Object.values(tokens).map((token: any) => (
|
||||
<Box pl={4} my={1} key={token.id}>
|
||||
<Label sx={{ fontFamily: "body2" }}>
|
||||
<Checkbox
|
||||
checked={token.checked}
|
||||
onChange={(e) => handleTokenChange(e, token)}
|
||||
disabled={token.id in tokenUsedCount}
|
||||
/>
|
||||
{token.name}
|
||||
</Label>
|
||||
{token.id in tokenUsedCount && (
|
||||
<Text as="p" variant="caption" ml={4}>
|
||||
Token used in {tokenUsedCount[token.id]} selected map
|
||||
{tokenUsedCount[token.id] > 1 && "s"}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{tokenGroups.map(renderTokenGroup)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -1,63 +1,43 @@
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
import { Button, Flex, Label } from "theme-ui";
|
||||
import shortid from "shortid";
|
||||
import Case from "case";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Flex, Label, Box } from "theme-ui";
|
||||
import { useToasts } from "react-toast-notifications";
|
||||
import ReactResizeDetector from "react-resize-detector";
|
||||
|
||||
import EditMapModal from "./EditMapModal";
|
||||
import EditGroupModal from "./EditGroupModal";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import MapTiles from "../components/map/MapTiles";
|
||||
import ImageDrop from "../components/ImageDrop";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
|
||||
import blobToBuffer from "../helpers/blobToBuffer";
|
||||
import { resizeImage, createThumbnail } from "../helpers/image";
|
||||
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
|
||||
import {
|
||||
getGridDefaultInset,
|
||||
getGridSizeFromImage,
|
||||
gridSizeVaild,
|
||||
} from "../helpers/grid";
|
||||
import Vector2 from "../helpers/Vector2";
|
||||
import ImageDrop from "../components/image/ImageDrop";
|
||||
|
||||
import MapTiles from "../components/map/MapTiles";
|
||||
import MapEditBar from "../components/map/MapEditBar";
|
||||
import SelectMapSelectButton from "../components/map/SelectMapSelectButton";
|
||||
|
||||
import TilesOverlay from "../components/tile/TilesOverlay";
|
||||
import TilesContainer from "../components/tile/TilesContainer";
|
||||
import TileActionBar from "../components/tile/TileActionBar";
|
||||
|
||||
import { findGroup, getItemNames } from "../helpers/group";
|
||||
import { createMapFromFile } from "../helpers/map";
|
||||
|
||||
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
||||
|
||||
import { useMapData } from "../contexts/MapDataContext";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
|
||||
|
||||
import shortcuts from "../shortcuts";
|
||||
import { Map, MapState } from "../components/map/Map";
|
||||
import { useUserId } from "../contexts/UserIdContext";
|
||||
import { useAssets } from "../contexts/AssetsContext";
|
||||
import { GroupProvider } from "../contexts/GroupContext";
|
||||
import { TileDragProvider } from "../contexts/TileDragContext";
|
||||
|
||||
type SelectMapProps = {
|
||||
isOpen: boolean,
|
||||
onDone: any,
|
||||
onMapChange: any,
|
||||
onMapReset: any,
|
||||
currentMap: any
|
||||
}
|
||||
|
||||
const defaultMapProps = {
|
||||
showGrid: false,
|
||||
snapToGrid: true,
|
||||
quality: "original",
|
||||
group: "",
|
||||
isOpen: boolean;
|
||||
onDone: any;
|
||||
onMapChange: any;
|
||||
onMapReset: any;
|
||||
currentMap: any;
|
||||
};
|
||||
|
||||
const mapResolutions = [
|
||||
{
|
||||
size: 30, // Pixels per grid
|
||||
quality: 0.5, // JPEG compression quality
|
||||
id: "low",
|
||||
},
|
||||
{ size: 70, quality: 0.6, id: "medium" },
|
||||
{ size: 140, quality: 0.7, id: "high" },
|
||||
{ size: 300, quality: 0.8, id: "ultra" },
|
||||
];
|
||||
|
||||
function SelectMapModal({
|
||||
isOpen,
|
||||
onDone,
|
||||
@@ -65,51 +45,30 @@ function SelectMapModal({
|
||||
onMapReset,
|
||||
// The map currently being view in the map screen
|
||||
currentMap,
|
||||
}: SelectMapProps ) {
|
||||
}: SelectMapProps) {
|
||||
const { addToast } = useToasts();
|
||||
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
const {
|
||||
ownedMaps,
|
||||
maps,
|
||||
mapStates,
|
||||
mapGroups,
|
||||
addMap,
|
||||
removeMaps,
|
||||
resetMap,
|
||||
updateMap,
|
||||
updateMaps,
|
||||
mapsLoading,
|
||||
getMapFromDB,
|
||||
getMapStateFromDB,
|
||||
getMapState,
|
||||
getMap,
|
||||
updateMapGroups,
|
||||
updateMap,
|
||||
updateMapState,
|
||||
mapsById,
|
||||
} = useMapData();
|
||||
const { addAssets } = useAssets();
|
||||
|
||||
/**
|
||||
* Search
|
||||
*/
|
||||
const [search, setSearch] = useState("");
|
||||
const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
|
||||
|
||||
function handleSearchChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setSearch(event.target.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group
|
||||
*/
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
|
||||
async function handleMapsGroup(group: any) {
|
||||
setIsLoading(true);
|
||||
setIsGroupModalOpen(false);
|
||||
await updateMaps(selectedMapIds, { group });
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const [mapsByGroup, mapGroups] = useGroup(
|
||||
ownedMaps,
|
||||
filteredMaps,
|
||||
!!search,
|
||||
filteredMapScores
|
||||
);
|
||||
// Get map names for group filtering
|
||||
const [mapNames, setMapNames] = useState(getItemNames(maps));
|
||||
useEffect(() => {
|
||||
setMapNames(getItemNames(maps));
|
||||
}, [maps]);
|
||||
|
||||
/**
|
||||
* Image Upload
|
||||
@@ -118,9 +77,8 @@ function SelectMapModal({
|
||||
const fileInputRef = useRef<any>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
|
||||
false
|
||||
);
|
||||
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
|
||||
useState(false);
|
||||
const largeImageWarningFiles = useRef<any>();
|
||||
|
||||
async function handleImagesUpload(files: any) {
|
||||
@@ -159,6 +117,12 @@ function SelectMapModal({
|
||||
}
|
||||
}
|
||||
|
||||
function openImageDialog() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLargeImageWarningCancel() {
|
||||
largeImageWarningFiles.current = undefined;
|
||||
setShowLargeImageWarning(false);
|
||||
@@ -176,196 +140,13 @@ function SelectMapModal({
|
||||
}
|
||||
|
||||
async function handleImageUpload(file: File) {
|
||||
if (!file) {
|
||||
return Promise.reject();
|
||||
}
|
||||
let image = new Image();
|
||||
setIsLoading(true);
|
||||
|
||||
const buffer = await blobToBuffer(file);
|
||||
// Copy file to avoid permissions issues
|
||||
const blob = new Blob([buffer]);
|
||||
// Create and load the image temporarily to get its dimensions
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
image.onload = async function () {
|
||||
// Find name and grid size
|
||||
let gridSize;
|
||||
let name = "Unknown Map";
|
||||
if (file.name) {
|
||||
if (file.name.matchAll) {
|
||||
// Match against a regex to find the grid size in the file name
|
||||
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
|
||||
const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
|
||||
for (let match of gridMatches) {
|
||||
const matchX = parseInt(match[1]);
|
||||
const matchY = parseInt(match[3]);
|
||||
if (
|
||||
!isNaN(matchX) &&
|
||||
!isNaN(matchY) &&
|
||||
gridSizeVaild(matchX, matchY)
|
||||
) {
|
||||
gridSize = { x: matchX, y: matchY };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!gridSize) {
|
||||
gridSize = await getGridSizeFromImage(image);
|
||||
}
|
||||
|
||||
// Remove file extension
|
||||
name = file.name.replace(/\.[^/.]+$/, "");
|
||||
// Removed grid size expression
|
||||
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
|
||||
// Clean string
|
||||
name = name.replace(/ +/g, " ");
|
||||
name = name.trim();
|
||||
// Capitalize and remove underscores
|
||||
name = Case.capital(name);
|
||||
}
|
||||
|
||||
if (!gridSize) {
|
||||
gridSize = { x: 22, y: 22 };
|
||||
}
|
||||
|
||||
// Create resolutions
|
||||
const resolutions: any = {};
|
||||
for (let resolution of mapResolutions) {
|
||||
const resolutionPixelSize: Vector2 = Vector2.multiply(
|
||||
gridSize,
|
||||
resolution.size
|
||||
);
|
||||
if (
|
||||
image.width >= resolutionPixelSize.x &&
|
||||
image.height >= resolutionPixelSize.y
|
||||
) {
|
||||
const resized = await resizeImage(
|
||||
image,
|
||||
Vector2.max(resolutionPixelSize, undefined) as number,
|
||||
file.type,
|
||||
resolution.quality
|
||||
);
|
||||
if (resized.blob) {
|
||||
const resizedBuffer = await blobToBuffer(resized.blob);
|
||||
resolutions[resolution.id] = {
|
||||
file: resizedBuffer,
|
||||
width: resized.width,
|
||||
height: resized.height,
|
||||
type: "file",
|
||||
id: resolution.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Create thumbnail
|
||||
const thumbnail = await createThumbnail(image, file.type);
|
||||
|
||||
handleMapAdd({
|
||||
// Save as a buffer to send with msgpack
|
||||
file: buffer,
|
||||
resolutions,
|
||||
thumbnail,
|
||||
name,
|
||||
type: "file",
|
||||
grid: {
|
||||
size: gridSize,
|
||||
inset: getGridDefaultInset(
|
||||
{ size: gridSize, type: "square" },
|
||||
image.width,
|
||||
image.height
|
||||
),
|
||||
type: "square",
|
||||
measurement: {
|
||||
type: "chebyshev",
|
||||
scale: "5ft",
|
||||
},
|
||||
},
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
id: shortid.generate(),
|
||||
created: Date.now(),
|
||||
lastModified: Date.now(),
|
||||
lastUsed: Date.now(),
|
||||
owner: userId,
|
||||
...defaultMapProps,
|
||||
});
|
||||
setIsLoading(false);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(undefined);
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function openImageDialog() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Controls
|
||||
*/
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
// The map selected in the modal
|
||||
const [selectedMapIds, setSelectedMapIds] = useState<string[]>([]);
|
||||
|
||||
const selectedMaps: Map[] = ownedMaps.filter((map: Map) =>
|
||||
selectedMapIds.includes(map.id)
|
||||
);
|
||||
const selectedMapStates = mapStates.filter((state: MapState) =>
|
||||
selectedMapIds.includes(state.mapId)
|
||||
);
|
||||
|
||||
async function handleMapAdd(map: any) {
|
||||
const { map, assets } = await createMapFromFile(file, userId);
|
||||
await addMap(map);
|
||||
setSelectedMapIds([map.id]);
|
||||
}
|
||||
|
||||
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
|
||||
async function handleMapsRemove() {
|
||||
setIsLoading(true);
|
||||
setIsMapsRemoveModalOpen(false);
|
||||
await removeMaps(selectedMapIds);
|
||||
setSelectedMapIds([]);
|
||||
// Removed the map from the map screen if needed
|
||||
if (currentMap && selectedMapIds.includes(currentMap.id)) {
|
||||
onMapChange(null, null);
|
||||
}
|
||||
await addAssets(assets);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const [isMapsResetModalOpen, setIsMapsResetModalOpen] = useState(false);
|
||||
async function handleMapsReset() {
|
||||
setIsLoading(true);
|
||||
setIsMapsResetModalOpen(false);
|
||||
for (let id of selectedMapIds) {
|
||||
const newState = await resetMap(id);
|
||||
// Reset the state of the current map if needed
|
||||
if (currentMap && currentMap.id === id) {
|
||||
onMapReset(newState);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// Either single, multiple or range
|
||||
const [selectMode, setSelectMode] = useState("single");
|
||||
|
||||
function handleMapSelect(map: any) {
|
||||
handleItemSelect(
|
||||
map,
|
||||
selectMode,
|
||||
selectedMapIds,
|
||||
setSelectedMapIds,
|
||||
mapsByGroup,
|
||||
mapGroups
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal Controls
|
||||
*/
|
||||
@@ -374,176 +155,157 @@ function SelectMapModal({
|
||||
onDone();
|
||||
}
|
||||
|
||||
async function handleDone() {
|
||||
/**
|
||||
* Map Controls
|
||||
*/
|
||||
async function handleMapSelect(mapId: string) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (selectedMapIds.length === 1) {
|
||||
// Update last used for cache invalidation
|
||||
const lastUsed = Date.now();
|
||||
const map = selectedMaps[0];
|
||||
const mapState = await getMapStateFromDB(map.id);
|
||||
if (map.type === "file") {
|
||||
setIsLoading(true);
|
||||
await updateMap(map.id, { lastUsed });
|
||||
const updatedMap = await getMapFromDB(map.id);
|
||||
onMapChange(updatedMap, mapState);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
onMapChange(map, mapState);
|
||||
}
|
||||
if (mapId) {
|
||||
setIsLoading(true);
|
||||
const map = await getMap(mapId);
|
||||
const mapState = await getMapState(mapId);
|
||||
onMapChange(map, mapState);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
onMapChange(null, null);
|
||||
}
|
||||
onDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcuts
|
||||
*/
|
||||
function handleKeyDown(event: KeyboardEvent): KeyboardEvent | void {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event)) {
|
||||
setSelectMode("range");
|
||||
}
|
||||
if (shortcuts.selectMultiple(event)) {
|
||||
setSelectMode("multiple");
|
||||
}
|
||||
if (shortcuts.delete(event)) {
|
||||
// Selected maps and none are default
|
||||
if (
|
||||
selectedMapIds.length > 0 &&
|
||||
!selectedMaps.some((map: any) => map.type === "default")
|
||||
) {
|
||||
// Ensure all other modals are closed
|
||||
setIsGroupModalOpen(false);
|
||||
setIsEditModalOpen(false);
|
||||
setIsMapsResetModalOpen(false);
|
||||
setIsMapsRemoveModalOpen(true);
|
||||
}
|
||||
const [editingMapId, setEditingMapId] = useState<string>();
|
||||
|
||||
const [isDraggingMap, setIsDraggingMap] = useState(false);
|
||||
|
||||
const [canAddDraggedMap, setCanAddDraggedMap] = useState(false);
|
||||
function handleGroupsSelect(groupIds: string[]) {
|
||||
if (groupIds.length === 1) {
|
||||
// Only allow adding a map from dragging if there is a single group item selected
|
||||
const group = findGroup(mapGroups, groupIds[0]);
|
||||
setCanAddDraggedMap(group && group.type === "item");
|
||||
} else {
|
||||
setCanAddDraggedMap(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event) && selectMode === "range") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
|
||||
setSelectMode("single");
|
||||
function handleDragAdd(groupIds: string[]) {
|
||||
if (groupIds.length === 1) {
|
||||
handleMapSelect(groupIds[0]);
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKeyDown, handleKeyUp);
|
||||
|
||||
// Set select mode to single when cmd+tabing
|
||||
function handleBlur() {
|
||||
setSelectMode("single");
|
||||
}
|
||||
|
||||
useBlur(handleBlur);
|
||||
|
||||
const layout = useResponsiveLayout();
|
||||
|
||||
const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
|
||||
function handleModalResize(width: number, height: number) {
|
||||
setModalSize({ width, height });
|
||||
}
|
||||
|
||||
const editingMap =
|
||||
editingMapId && maps.find((map) => map.id === editingMapId);
|
||||
const editingMapState =
|
||||
editingMapId && mapStates.find((state) => state.mapId === editingMapId);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
style={{ content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" } }}
|
||||
style={{
|
||||
content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" },
|
||||
}}
|
||||
shouldCloseOnEsc={!isDraggingMap}
|
||||
>
|
||||
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to upload">
|
||||
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to import">
|
||||
<input
|
||||
onChange={(event) => handleImagesUpload(event.target.files)}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/jpeg, image/gif, image/png, image/webp"
|
||||
style={{ display: "none" }}
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
<ReactResizeDetector
|
||||
handleWidth
|
||||
handleHeight
|
||||
onResize={handleModalResize}
|
||||
refreshMode="debounce"
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Select or import a map
|
||||
</Label>
|
||||
<MapTiles
|
||||
maps={mapsByGroup}
|
||||
<GroupProvider
|
||||
groups={mapGroups}
|
||||
onMapAdd={openImageDialog}
|
||||
onMapEdit={() => setIsEditModalOpen(true)}
|
||||
onMapsReset={() => setIsMapsResetModalOpen(true)}
|
||||
onMapsRemove={() => setIsMapsRemoveModalOpen(true)}
|
||||
selectedMaps={selectedMaps}
|
||||
selectedMapStates={selectedMapStates}
|
||||
onMapSelect={handleMapSelect}
|
||||
onDone={handleDone}
|
||||
selectMode={selectMode}
|
||||
onSelectModeChange={setSelectMode}
|
||||
search={search}
|
||||
onSearchChange={handleSearchChange}
|
||||
onMapsGroup={() => setIsGroupModalOpen(true)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isLoading || selectedMapIds.length > 1}
|
||||
onClick={handleDone}
|
||||
mt={2}
|
||||
itemNames={mapNames}
|
||||
onGroupsChange={updateMapGroups}
|
||||
onGroupsSelect={handleGroupsSelect}
|
||||
disabled={!isOpen}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Select or import a map
|
||||
</Label>
|
||||
<TileActionBar onAdd={openImageDialog} addTitle="Import Map(s)" />
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<TileDragProvider
|
||||
onDragAdd={canAddDraggedMap && handleDragAdd}
|
||||
onDragStart={() => setIsDraggingMap(true)}
|
||||
onDragEnd={() => setIsDraggingMap(false)}
|
||||
onDragCancel={() => setIsDraggingMap(false)}
|
||||
>
|
||||
<TilesContainer>
|
||||
<MapTiles
|
||||
mapsById={mapsById}
|
||||
onMapEdit={setEditingMapId}
|
||||
onMapSelect={handleMapSelect}
|
||||
/>
|
||||
</TilesContainer>
|
||||
</TileDragProvider>
|
||||
<TileDragProvider
|
||||
onDragAdd={canAddDraggedMap && handleDragAdd}
|
||||
onDragStart={() => setIsDraggingMap(true)}
|
||||
onDragEnd={() => setIsDraggingMap(false)}
|
||||
onDragCancel={() => setIsDraggingMap(false)}
|
||||
>
|
||||
<TilesOverlay modalSize={modalSize}>
|
||||
<MapTiles
|
||||
mapsById={mapsById}
|
||||
onMapEdit={setEditingMapId}
|
||||
onMapSelect={handleMapSelect}
|
||||
subgroup
|
||||
/>
|
||||
</TilesOverlay>
|
||||
</TileDragProvider>
|
||||
<MapEditBar
|
||||
currentMap={currentMap}
|
||||
disabled={isLoading || editingMapId}
|
||||
onMapChange={onMapChange}
|
||||
onMapReset={onMapReset}
|
||||
onLoad={setIsLoading}
|
||||
/>
|
||||
</Box>
|
||||
<SelectMapSelectButton
|
||||
onMapSelect={handleMapSelect}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Flex>
|
||||
</GroupProvider>
|
||||
</ReactResizeDetector>
|
||||
</ImageDrop>
|
||||
<>{(isLoading || mapsLoading) && <LoadingOverlay bg="overlay" />}</>
|
||||
<>
|
||||
{(isLoading || mapsLoading) && <LoadingOverlay bg="overlay" />}
|
||||
</>
|
||||
<EditMapModal
|
||||
isOpen={isEditModalOpen}
|
||||
onDone={() => setIsEditModalOpen(false)}
|
||||
// TODO: check with Mitch what to do here if length > 1
|
||||
//selectedMaps.length === 1 &&
|
||||
mapId={selectedMaps[0].id}
|
||||
/>
|
||||
<EditGroupModal
|
||||
isOpen={isGroupModalOpen}
|
||||
onChange={handleMapsGroup}
|
||||
groups={mapGroups.filter(
|
||||
(group: any) => group !== "" && group !== "default"
|
||||
{editingMap && editingMapState && (
|
||||
<EditMapModal
|
||||
isOpen={!!editingMapId}
|
||||
onDone={() => setEditingMapId(undefined)}
|
||||
map={editingMap}
|
||||
mapState={editingMapState}
|
||||
onUpdateMap={updateMap}
|
||||
onUpdateMapState={updateMapState}
|
||||
/>
|
||||
)}
|
||||
onRequestClose={() => setIsGroupModalOpen(false)}
|
||||
// Select the default group by testing whether all selected maps are the same
|
||||
defaultGroup={
|
||||
selectedMaps.length > 0 &&
|
||||
selectedMaps
|
||||
.map((map: any) => map.group)
|
||||
.reduce((prev: any, curr: any) => (prev === curr ? curr : undefined))
|
||||
}
|
||||
/>
|
||||
<ConfirmModal
|
||||
isOpen={isMapsResetModalOpen}
|
||||
onRequestClose={() => setIsMapsResetModalOpen(false)}
|
||||
onConfirm={handleMapsReset}
|
||||
confirmText="Reset"
|
||||
label={`Reset ${selectedMapIds.length} Map${
|
||||
selectedMapIds.length > 1 ? "s" : ""
|
||||
}`}
|
||||
description="This will remove all fog, drawings and tokens from the selected maps."
|
||||
/>
|
||||
<ConfirmModal
|
||||
isOpen={isMapsRemoveModalOpen}
|
||||
onRequestClose={() => setIsMapsRemoveModalOpen(false)}
|
||||
onConfirm={handleMapsRemove}
|
||||
confirmText="Remove"
|
||||
label={`Remove ${selectedMapIds.length} Map${
|
||||
selectedMapIds.length > 1 ? "s" : ""
|
||||
}`}
|
||||
description="This operation cannot be undone."
|
||||
/>
|
||||
</>
|
||||
<ConfirmModal
|
||||
isOpen={isLargeImageWarningModalOpen}
|
||||
onRequestClose={handleLargeImageWarningCancel}
|
||||
|
||||
@@ -1,71 +1,72 @@
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
import { Flex, Label, Button } from "theme-ui";
|
||||
import shortid from "shortid";
|
||||
import Case from "case";
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { Flex, Label, Button, Box } from "theme-ui";
|
||||
import { useToasts } from "react-toast-notifications";
|
||||
import ReactResizeDetector from "react-resize-detector";
|
||||
|
||||
import EditTokenModal from "./EditTokenModal";
|
||||
import EditGroupModal from "./EditGroupModal";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import ImageDrop from "../components/ImageDrop";
|
||||
import TokenTiles from "../components/token/TokenTiles";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
|
||||
import blobToBuffer from "../helpers/blobToBuffer";
|
||||
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
|
||||
import { createThumbnail } from "../helpers/image";
|
||||
import ImageDrop from "../components/image/ImageDrop";
|
||||
|
||||
import TokenTiles from "../components/token/TokenTiles";
|
||||
import TokenEditBar from "../components/token/TokenEditBar";
|
||||
|
||||
import TilesOverlay from "../components/tile/TilesOverlay";
|
||||
import TilesContainer from "../components/tile/TilesContainer";
|
||||
import TileActionBar from "../components/tile/TileActionBar";
|
||||
|
||||
import { getGroupItems, getItemNames } from "../helpers/group";
|
||||
import {
|
||||
createTokenFromFile,
|
||||
createTokenState,
|
||||
clientPositionToMapPosition,
|
||||
} from "../helpers/token";
|
||||
import Vector2 from "../helpers/Vector2";
|
||||
|
||||
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
||||
|
||||
import { useTokenData } from "../contexts/TokenDataContext";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
|
||||
import { useUserId } from "../contexts/UserIdContext";
|
||||
import { useAssets } from "../contexts/AssetsContext";
|
||||
import { GroupProvider } from "../contexts/GroupContext";
|
||||
import { TileDragProvider } from "../contexts/TileDragContext";
|
||||
import { useMapStage } from "../contexts/MapStageContext";
|
||||
|
||||
import shortcuts from "../shortcuts";
|
||||
import { FileToken, Token } from "../tokens";
|
||||
import { TokenState } from "../components/map/Map";
|
||||
|
||||
function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: any }) {
|
||||
type SelectTokensModalProps = {
|
||||
isOpen: boolean;
|
||||
onRequestClose: () => void;
|
||||
onMapTokensStateCreate: (states: TokenState[]) => void;
|
||||
};
|
||||
|
||||
function SelectTokensModal({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
onMapTokensStateCreate,
|
||||
}: SelectTokensModalProps) {
|
||||
const { addToast } = useToasts();
|
||||
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
const {
|
||||
ownedTokens,
|
||||
tokens,
|
||||
addToken,
|
||||
removeTokens,
|
||||
updateTokens,
|
||||
tokensLoading,
|
||||
tokenGroups,
|
||||
updateTokenGroups,
|
||||
updateToken,
|
||||
tokensById,
|
||||
} = useTokenData();
|
||||
const { addAssets } = useAssets();
|
||||
|
||||
/**
|
||||
* Search
|
||||
*/
|
||||
const [search, setSearch] = useState("");
|
||||
const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
|
||||
|
||||
function handleSearchChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setSearch(event.target.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group
|
||||
*/
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
|
||||
async function handleTokensGroup(group: string) {
|
||||
setIsLoading(true);
|
||||
setIsGroupModalOpen(false);
|
||||
await updateTokens(selectedTokenIds, { group });
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const [tokensByGroup, tokenGroups] = useGroup(
|
||||
ownedTokens,
|
||||
filteredTokens,
|
||||
!!search,
|
||||
filteredTokenScores
|
||||
);
|
||||
// Get token names for group filtering
|
||||
const [tokenNames, setTokenNames] = useState(getItemNames(tokens));
|
||||
useEffect(() => {
|
||||
setTokenNames(getItemNames(tokens));
|
||||
}, [tokens]);
|
||||
|
||||
/**
|
||||
* Image Upload
|
||||
@@ -74,18 +75,11 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
const fileInputRef = useRef<any>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
|
||||
false
|
||||
);
|
||||
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
|
||||
useState(false);
|
||||
const largeImageWarningFiles = useRef<File[]>();
|
||||
|
||||
function openImageDialog() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImagesUpload(files: FileList | null) {
|
||||
async function handleImagesUpload(files: FileList) {
|
||||
if (navigator.storage) {
|
||||
// Attempt to enable persistant storage
|
||||
await navigator.storage.persist();
|
||||
@@ -126,6 +120,12 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
}
|
||||
}
|
||||
|
||||
function openImageDialog() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLargeImageWarningCancel() {
|
||||
largeImageWarningFiles.current = undefined;
|
||||
setShowLargeImageWarning(false);
|
||||
@@ -146,238 +146,177 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
|
||||
}
|
||||
|
||||
async function handleImageUpload(file: File) {
|
||||
let name = "Unknown Token";
|
||||
if (file.name) {
|
||||
// Remove file extension
|
||||
name = file.name.replace(/\.[^/.]+$/, "");
|
||||
// Removed grid size expression
|
||||
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
|
||||
// Clean string
|
||||
name = name.replace(/ +/g, " ");
|
||||
name = name.trim();
|
||||
// Capitalize and remove underscores
|
||||
name = Case.capital(name);
|
||||
}
|
||||
let image = new Image();
|
||||
setIsLoading(true);
|
||||
const buffer = await blobToBuffer(file);
|
||||
|
||||
// Copy file to avoid permissions issues
|
||||
const blob = new Blob([buffer]);
|
||||
// Create and load the image temporarily to get its dimensions
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
image.onload = async function () {
|
||||
const thumbnail = await createThumbnail(image, file.type);
|
||||
|
||||
handleTokenAdd({
|
||||
file: buffer,
|
||||
thumbnail,
|
||||
name,
|
||||
id: shortid.generate(),
|
||||
type: "file",
|
||||
created: Date.now(),
|
||||
lastModified: Date.now(),
|
||||
lastUsed: Date.now(),
|
||||
owner: userId,
|
||||
defaultSize: 1,
|
||||
category: "character",
|
||||
hideInSidebar: false,
|
||||
group: "",
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
setIsLoading(false);
|
||||
resolve(undefined);
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = url;
|
||||
});
|
||||
const { token, assets } = await createTokenFromFile(file, userId);
|
||||
await addToken(token);
|
||||
await addAssets(assets);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token controls
|
||||
*/
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState<boolean>(false);
|
||||
const [selectedTokenIds, setSelectedTokenIds] = useState<string[]>([]);
|
||||
const selectedTokens = ownedTokens.filter((token) =>
|
||||
selectedTokenIds.includes(token.id)
|
||||
);
|
||||
const [editingTokenId, setEditingTokenId] = useState<string>();
|
||||
|
||||
function handleTokenAdd(token: FileToken) {
|
||||
addToken(token);
|
||||
setSelectedTokenIds([token.id]);
|
||||
}
|
||||
const [isDraggingToken, setIsDraggingToken] = useState(false);
|
||||
|
||||
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
|
||||
async function handleTokensRemove() {
|
||||
setIsLoading(true);
|
||||
setIsTokensRemoveModalOpen(false);
|
||||
await removeTokens(selectedTokenIds);
|
||||
setSelectedTokenIds([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function handleTokensHide(hideInSidebar: boolean) {
|
||||
setIsLoading(true);
|
||||
await updateTokens(selectedTokenIds, { hideInSidebar });
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// Either single, multiple or range
|
||||
const [selectMode, setSelectMode] = useState("single");
|
||||
|
||||
async function handleTokenSelect(token: Token) {
|
||||
handleItemSelect(
|
||||
token,
|
||||
selectMode,
|
||||
selectedTokenIds,
|
||||
setSelectedTokenIds,
|
||||
tokensByGroup,
|
||||
tokenGroups
|
||||
const mapStageRef = useMapStage();
|
||||
function handleTokensAddToMap(groupIds: string[], rect: any) {
|
||||
let clientPosition = new Vector2(
|
||||
rect.width / 2 + rect.left,
|
||||
rect.height / 2 + rect.top
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcuts
|
||||
*/
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isOpen) {
|
||||
const mapStage = mapStageRef.current;
|
||||
if (!mapStage) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event)) {
|
||||
setSelectMode("range");
|
||||
|
||||
let position = clientPositionToMapPosition(mapStage, clientPosition, false);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectMultiple(event)) {
|
||||
setSelectMode("multiple");
|
||||
}
|
||||
if (shortcuts.delete(event)) {
|
||||
// Selected tokens and none are default
|
||||
if (
|
||||
selectedTokenIds.length > 0 &&
|
||||
!selectedTokens.some((token) => token.type === "default")
|
||||
) {
|
||||
// Ensure all other modals are closed
|
||||
setIsEditModalOpen(false);
|
||||
setIsGroupModalOpen(false);
|
||||
setIsTokensRemoveModalOpen(true);
|
||||
|
||||
let newTokenStates = [];
|
||||
|
||||
for (let id of groupIds) {
|
||||
if (id in tokensById) {
|
||||
newTokenStates.push(createTokenState(tokensById[id], position, userId));
|
||||
position = Vector2.add(position, 0.01);
|
||||
} else {
|
||||
// Check if a group is selected
|
||||
const group = tokenGroups.find(
|
||||
(group) => group.id === id && group.type === "group"
|
||||
);
|
||||
if (group) {
|
||||
// Add all tokens of group
|
||||
const items = getGroupItems(group);
|
||||
for (let item of items) {
|
||||
if (item.id in tokensById) {
|
||||
newTokenStates.push(
|
||||
createTokenState(tokensById[item.id], position, userId)
|
||||
);
|
||||
position = Vector2.add(position, 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event) && selectMode === "range") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
|
||||
setSelectMode("single");
|
||||
if (newTokenStates.length > 0) {
|
||||
onMapTokensStateCreate(newTokenStates);
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKeyDown, handleKeyUp);
|
||||
|
||||
// Set select mode to single when cmd+tabing
|
||||
function handleBlur() {
|
||||
setSelectMode("single");
|
||||
}
|
||||
|
||||
useBlur(handleBlur);
|
||||
|
||||
const layout = useResponsiveLayout();
|
||||
|
||||
let tokenId;
|
||||
if (selectedTokens.length === 1 && selectedTokens[0].id) {
|
||||
tokenId = selectedTokens[0].id
|
||||
} else {
|
||||
// TODO: handle tokenId not found
|
||||
tokenId = ""
|
||||
const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
|
||||
function handleModalResize(width: number, height: number) {
|
||||
setModalSize({ width, height });
|
||||
}
|
||||
|
||||
const editingToken =
|
||||
editingTokenId && tokens.find((token) => token.id === editingTokenId);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
style={{ content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" } }}
|
||||
style={{
|
||||
content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" },
|
||||
}}
|
||||
shouldCloseOnEsc={!isDraggingToken}
|
||||
>
|
||||
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to upload">
|
||||
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to import">
|
||||
<input
|
||||
onChange={(event) => handleImagesUpload(event.target.files)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
event.target.files && handleImagesUpload(event.target.files)
|
||||
}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/jpeg, image/gif, image/png, image/webp"
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
<ReactResizeDetector
|
||||
handleWidth
|
||||
handleHeight
|
||||
onResize={handleModalResize}
|
||||
refreshMode="debounce"
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Edit or import a token
|
||||
</Label>
|
||||
<TokenTiles
|
||||
tokens={tokensByGroup}
|
||||
<GroupProvider
|
||||
groups={tokenGroups}
|
||||
onTokenAdd={openImageDialog}
|
||||
onTokenEdit={() => setIsEditModalOpen(true)}
|
||||
onTokensRemove={() => setIsTokensRemoveModalOpen(true)}
|
||||
selectedTokens={selectedTokens}
|
||||
onTokenSelect={handleTokenSelect}
|
||||
selectMode={selectMode}
|
||||
onSelectModeChange={setSelectMode}
|
||||
search={search}
|
||||
onSearchChange={handleSearchChange}
|
||||
onTokensGroup={() => setIsGroupModalOpen(true)}
|
||||
onTokensHide={handleTokensHide}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
onClick={onRequestClose}
|
||||
mt={2}
|
||||
itemNames={tokenNames}
|
||||
onGroupsChange={updateTokenGroups}
|
||||
disabled={!isOpen}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Edit or import a token
|
||||
</Label>
|
||||
<TileActionBar
|
||||
onAdd={openImageDialog}
|
||||
addTitle="Import Token(s)"
|
||||
/>
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<TileDragProvider
|
||||
onDragAdd={handleTokensAddToMap}
|
||||
onDragStart={() => setIsDraggingToken(true)}
|
||||
onDragEnd={() => setIsDraggingToken(false)}
|
||||
onDragCancel={() => setIsDraggingToken(false)}
|
||||
>
|
||||
<TilesContainer>
|
||||
<TokenTiles
|
||||
tokensById={tokensById}
|
||||
onTokenEdit={setEditingTokenId}
|
||||
/>
|
||||
</TilesContainer>
|
||||
</TileDragProvider>
|
||||
<TileDragProvider
|
||||
onDragAdd={handleTokensAddToMap}
|
||||
onDragStart={() => setIsDraggingToken(true)}
|
||||
onDragEnd={() => setIsDraggingToken(false)}
|
||||
onDragCancel={() => setIsDraggingToken(false)}
|
||||
>
|
||||
<TilesOverlay modalSize={modalSize}>
|
||||
<TokenTiles
|
||||
tokensById={tokensById}
|
||||
onTokenEdit={setEditingTokenId}
|
||||
subgroup
|
||||
/>
|
||||
</TilesOverlay>
|
||||
</TileDragProvider>
|
||||
<TokenEditBar
|
||||
onLoad={setIsLoading}
|
||||
disabled={isLoading || !isOpen}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
onClick={onRequestClose}
|
||||
mt={2}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Flex>
|
||||
</GroupProvider>
|
||||
</ReactResizeDetector>
|
||||
</ImageDrop>
|
||||
<>{(isLoading || tokensLoading) && <LoadingOverlay bg="overlay" />}</>
|
||||
<>
|
||||
{(isLoading || tokensLoading) && <LoadingOverlay bg="overlay" />}
|
||||
</>
|
||||
<EditTokenModal
|
||||
isOpen={isEditModalOpen}
|
||||
onDone={() => setIsEditModalOpen(false)}
|
||||
tokenId={tokenId}
|
||||
/>
|
||||
<EditGroupModal
|
||||
isOpen={isGroupModalOpen}
|
||||
onChange={handleTokensGroup}
|
||||
groups={tokenGroups.filter(
|
||||
(group: string) => group !== "" && group !== "default"
|
||||
{editingToken && (
|
||||
<EditTokenModal
|
||||
isOpen={!!editingTokenId}
|
||||
onDone={() => setEditingTokenId(undefined)}
|
||||
token={editingToken}
|
||||
onUpdateToken={updateToken}
|
||||
/>
|
||||
)}
|
||||
onRequestClose={() => setIsGroupModalOpen(false)}
|
||||
// Select the default group by testing whether all selected tokens are the same
|
||||
defaultGroup={
|
||||
selectedTokens.length > 0 &&
|
||||
selectedTokens
|
||||
.map((map) => map.group)
|
||||
.reduce((prev, curr) => (prev === curr ? curr : undefined))
|
||||
}
|
||||
/>
|
||||
<ConfirmModal
|
||||
isOpen={isTokensRemoveModalOpen}
|
||||
onRequestClose={() => setIsTokensRemoveModalOpen(false)}
|
||||
onConfirm={handleTokensRemove}
|
||||
confirmText="Remove"
|
||||
label={`Remove ${selectedTokenIds.length} Token${
|
||||
selectedTokenIds.length > 1 ? "s" : ""
|
||||
}`}
|
||||
description="This operation cannot be undone."
|
||||
/>
|
||||
</>
|
||||
<ConfirmModal
|
||||
isOpen={isLargeImageWarningModalOpen}
|
||||
onRequestClose={handleLargeImageWarningCancel}
|
||||
|
||||
@@ -14,7 +14,7 @@ import Modal from "../components/Modal";
|
||||
import Slider from "../components/Slider";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useUserId } from "../contexts/UserIdContext";
|
||||
import { useDatabase } from "../contexts/DatabaseContext";
|
||||
|
||||
import useSetting from "../hooks/useSetting";
|
||||
@@ -23,9 +23,15 @@ import ConfirmModal from "./ConfirmModal";
|
||||
import ImportExportModal from "./ImportExportModal";
|
||||
import { MapState } from "../components/map/Map";
|
||||
|
||||
function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: () => void }) {
|
||||
function SettingsModal({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onRequestClose: () => void;
|
||||
}) {
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [labelSize, setLabelSize] = useSetting("map.labelSize");
|
||||
const [gridSnappingSensitivity, setGridSnappingSensitivity] = useSetting(
|
||||
@@ -58,9 +64,14 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
|
||||
|
||||
async function handleEraseAllData() {
|
||||
setIsLoading(true);
|
||||
localStorage.clear();
|
||||
await database?.delete();
|
||||
window.location.reload();
|
||||
try {
|
||||
localStorage.clear();
|
||||
database?.close();
|
||||
await database?.delete();
|
||||
} catch {
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
@@ -68,32 +79,28 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
|
||||
// Clear saved settings
|
||||
localStorage.clear();
|
||||
|
||||
//TODO: handle id database is undefined
|
||||
if (!database) {
|
||||
return;
|
||||
}
|
||||
// Clear map cache
|
||||
await database.table("maps").where("owner").notEqual(userId).delete();
|
||||
// Find all other peoples tokens who aren't benig used in a map state and delete them
|
||||
const tokens = await database
|
||||
.table("tokens")
|
||||
.where("owner")
|
||||
.notEqual(userId)
|
||||
.toArray();
|
||||
const states: MapState[] = await database?.table("states").toArray();
|
||||
for (let token of tokens) {
|
||||
let inUse = false;
|
||||
for (let state of states) {
|
||||
for (let tokenState of Object.values(state.tokens)) {
|
||||
if (token.id === tokenState.tokenId) {
|
||||
inUse = true;
|
||||
if (database && userId) {
|
||||
const assets = await database
|
||||
.table("assets")
|
||||
.where("owner")
|
||||
.notEqual(userId)
|
||||
.toArray();
|
||||
const states: MapState[] = await database.table("states").toArray();
|
||||
for (let asset of assets) {
|
||||
let inUse = false;
|
||||
for (let state of states) {
|
||||
for (let tokenState of Object.values(state.tokens)) {
|
||||
if (tokenState.type === "file" && asset.id === tokenState.file) {
|
||||
inUse = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!inUse) {
|
||||
database.table("tokens").delete(token.id);
|
||||
if (!inUse) {
|
||||
await database.table("assets").delete(asset.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@@ -191,13 +198,14 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
|
||||
Import / Export Data
|
||||
</Button>
|
||||
</Flex>
|
||||
{storageEstimate !&& (
|
||||
{storageEstimate! && (
|
||||
<Flex sx={{ justifyContent: "center" }}>
|
||||
<Text variant="caption">
|
||||
Storage Used: {prettyBytes(storageEstimate.usage as number)} of{" "}
|
||||
{prettyBytes(storageEstimate.quota as number)} (
|
||||
{Math.round(
|
||||
(storageEstimate.usage as number / Math.max(storageEstimate.quota as number, 1)) *
|
||||
((storageEstimate.usage as number) /
|
||||
Math.max(storageEstimate.quota as number, 1)) *
|
||||
100
|
||||
)}
|
||||
%)
|
||||
|
||||
Reference in New Issue
Block a user