Merge branch 'master' into typescript
This commit is contained in:
367
src/contexts/AssetsContext.js
Normal file
367
src/contexts/AssetsContext.js
Normal file
@@ -0,0 +1,367 @@
|
||||
import React, { useState, useContext, useCallback, useEffect } from "react";
|
||||
import * as Comlink from "comlink";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
|
||||
import { omit } from "../helpers/shared";
|
||||
|
||||
/**
|
||||
* @typedef Asset
|
||||
* @property {string} id
|
||||
* @property {number} width
|
||||
* @property {number} height
|
||||
* @property {Uint8Array} file
|
||||
* @property {string} mime
|
||||
* @property {string} owner
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback getAsset
|
||||
* @param {string} assetId
|
||||
* @returns {Promise<Asset|undefined>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback addAssets
|
||||
* @param {Asset[]} assets
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback putAsset
|
||||
* @param {Asset} asset
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef AssetsContext
|
||||
* @property {getAsset} getAsset
|
||||
* @property {addAssets} addAssets
|
||||
* @property {putAsset} putAsset
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {React.Context<undefined|AssetsContext>}
|
||||
*/
|
||||
const AssetsContext = React.createContext();
|
||||
|
||||
// 100 MB max cache size
|
||||
const maxCacheSize = 1e8;
|
||||
|
||||
export function AssetsProvider({ children }) {
|
||||
const { worker, database, databaseStatus } = useDatabase();
|
||||
|
||||
useEffect(() => {
|
||||
if (databaseStatus === "loaded") {
|
||||
worker.cleanAssetCache(maxCacheSize);
|
||||
}
|
||||
}, [worker, databaseStatus]);
|
||||
|
||||
const getAsset = useCallback(
|
||||
async (assetId) => {
|
||||
return await database.table("assets").get(assetId);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const addAssets = useCallback(
|
||||
async (assets) => {
|
||||
await database.table("assets").bulkAdd(assets);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const putAsset = useCallback(
|
||||
async (asset) => {
|
||||
// Check for broadcast channel and attempt to use worker to put map to avoid UI lockup
|
||||
// Safari doesn't support BC so fallback to single thread
|
||||
if (window.BroadcastChannel) {
|
||||
const packedAsset = encode(asset);
|
||||
const success = await worker.putData(
|
||||
Comlink.transfer(packedAsset, [packedAsset.buffer]),
|
||||
"assets"
|
||||
);
|
||||
if (!success) {
|
||||
await database.table("assets").put(asset);
|
||||
}
|
||||
} else {
|
||||
await database.table("assets").put(asset);
|
||||
}
|
||||
},
|
||||
[database, worker]
|
||||
);
|
||||
|
||||
const value = {
|
||||
getAsset,
|
||||
addAssets,
|
||||
putAsset,
|
||||
};
|
||||
|
||||
return (
|
||||
<AssetsContext.Provider value={value}>{children}</AssetsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAssets() {
|
||||
const context = useContext(AssetsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAssets must be used within a AssetsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef AssetURL
|
||||
* @property {string} url
|
||||
* @property {string} id
|
||||
* @property {number} references
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type React.Context<undefined|Object.<string, AssetURL>>
|
||||
*/
|
||||
export const AssetURLsStateContext = React.createContext();
|
||||
|
||||
/**
|
||||
* @type React.Context<undefined|React.Dispatch<React.SetStateAction<{}>>>
|
||||
*/
|
||||
export const AssetURLsUpdaterContext = React.createContext();
|
||||
|
||||
/**
|
||||
* Helper to manage sharing of custom image sources between uses of useAssetURL
|
||||
*/
|
||||
export function AssetURLsProvider({ children }) {
|
||||
const [assetURLs, setAssetURLs] = useState({});
|
||||
const { database } = useDatabase();
|
||||
|
||||
// Keep track of the assets that need to be loaded
|
||||
const [assetKeys, setAssetKeys] = useState([]);
|
||||
|
||||
// Load assets after 100ms
|
||||
const loadingDebouncedAssetURLs = useDebounce(assetURLs, 100);
|
||||
|
||||
// Update the asset keys to load when a url is added without an asset attached
|
||||
useEffect(() => {
|
||||
if (!loadingDebouncedAssetURLs) {
|
||||
return;
|
||||
}
|
||||
let keysToLoad = [];
|
||||
for (let url of Object.values(loadingDebouncedAssetURLs)) {
|
||||
if (url.url === null) {
|
||||
keysToLoad.push(url.id);
|
||||
}
|
||||
}
|
||||
if (keysToLoad.length > 0) {
|
||||
setAssetKeys(keysToLoad);
|
||||
}
|
||||
}, [loadingDebouncedAssetURLs]);
|
||||
|
||||
// Get the new assets whenever the keys change
|
||||
const assets = useLiveQuery(
|
||||
() => database?.table("assets").where("id").anyOf(assetKeys).toArray(),
|
||||
[database, assetKeys]
|
||||
);
|
||||
|
||||
// Update asset URLs when assets are loaded
|
||||
useEffect(() => {
|
||||
if (!assets || assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Assets are about to be loaded so clear the keys to load
|
||||
setAssetKeys([]);
|
||||
|
||||
setAssetURLs((prevURLs) => {
|
||||
let newURLs = { ...prevURLs };
|
||||
for (let asset of assets) {
|
||||
if (newURLs[asset.id]?.url === null) {
|
||||
newURLs[asset.id] = {
|
||||
...newURLs[asset.id],
|
||||
url: URL.createObjectURL(
|
||||
new Blob([asset.file], { type: asset.mime })
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
return newURLs;
|
||||
});
|
||||
}, [assets]);
|
||||
|
||||
// Clean up asset URLs every minute
|
||||
const cleanUpDebouncedAssetURLs = useDebounce(assetURLs, 60 * 1000);
|
||||
|
||||
// Revoke url when no more references
|
||||
useEffect(() => {
|
||||
setAssetURLs((prevURLs) => {
|
||||
let urlsToCleanup = [];
|
||||
for (let url of Object.values(prevURLs)) {
|
||||
if (url.references <= 0) {
|
||||
URL.revokeObjectURL(url.url);
|
||||
urlsToCleanup.push(url.id);
|
||||
}
|
||||
}
|
||||
if (urlsToCleanup.length > 0) {
|
||||
return omit(prevURLs, urlsToCleanup);
|
||||
} else {
|
||||
return prevURLs;
|
||||
}
|
||||
});
|
||||
}, [cleanUpDebouncedAssetURLs]);
|
||||
|
||||
return (
|
||||
<AssetURLsStateContext.Provider value={assetURLs}>
|
||||
<AssetURLsUpdaterContext.Provider value={setAssetURLs}>
|
||||
{children}
|
||||
</AssetURLsUpdaterContext.Provider>
|
||||
</AssetURLsStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to load either file or default asset into a URL
|
||||
* @param {string} assetId
|
||||
* @param {"file"|"default"} type
|
||||
* @param {Object.<string, string>} defaultSources
|
||||
* @param {string|undefined} unknownSource
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function useAssetURL(assetId, type, defaultSources, unknownSource) {
|
||||
const assetURLs = useContext(AssetURLsStateContext);
|
||||
if (assetURLs === undefined) {
|
||||
throw new Error("useAssetURL must be used within a AssetURLsProvider");
|
||||
}
|
||||
const setAssetURLs = useContext(AssetURLsUpdaterContext);
|
||||
if (setAssetURLs === undefined) {
|
||||
throw new Error("useAssetURL must be used within a AssetURLsProvider");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetId || type !== "file") {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateAssetURL() {
|
||||
function increaseReferences(prevURLs) {
|
||||
return {
|
||||
...prevURLs,
|
||||
[assetId]: {
|
||||
...prevURLs[assetId],
|
||||
references: prevURLs[assetId].references + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createReference(prevURLs) {
|
||||
return {
|
||||
...prevURLs,
|
||||
[assetId]: { url: null, id: assetId, references: 1 },
|
||||
};
|
||||
}
|
||||
setAssetURLs((prevURLs) => {
|
||||
if (assetId in prevURLs) {
|
||||
// Check if the asset url is already added and increase references
|
||||
return increaseReferences(prevURLs);
|
||||
} else {
|
||||
return createReference(prevURLs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateAssetURL();
|
||||
|
||||
return () => {
|
||||
// Decrease references
|
||||
setAssetURLs((prevURLs) => {
|
||||
if (assetId in prevURLs) {
|
||||
return {
|
||||
...prevURLs,
|
||||
[assetId]: {
|
||||
...prevURLs[assetId],
|
||||
references: prevURLs[assetId].references - 1,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return prevURLs;
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [assetId, setAssetURLs, type]);
|
||||
|
||||
if (!assetId) {
|
||||
return unknownSource;
|
||||
}
|
||||
|
||||
if (type === "default") {
|
||||
return defaultSources[assetId];
|
||||
}
|
||||
|
||||
if (type === "file") {
|
||||
return assetURLs[assetId]?.url || unknownSource;
|
||||
}
|
||||
|
||||
return unknownSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef FileData
|
||||
* @property {string} file
|
||||
* @property {"file"} type
|
||||
* @property {string} thumbnail
|
||||
* @property {string=} quality
|
||||
* @property {Object.<string, string>=} resolutions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DefaultData
|
||||
* @property {string} key
|
||||
* @property {"default"} type
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load a map or token into a URL taking into account a thumbnail and multiple resolutions
|
||||
* @param {FileData|DefaultData} data
|
||||
* @param {Object.<string, string>} defaultSources
|
||||
* @param {string|undefined} unknownSource
|
||||
* @param {boolean} thumbnail
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function useDataURL(
|
||||
data,
|
||||
defaultSources,
|
||||
unknownSource,
|
||||
thumbnail = false
|
||||
) {
|
||||
const [assetId, setAssetId] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
function loadAssetId() {
|
||||
if (data.type === "default") {
|
||||
setAssetId(data.key);
|
||||
} else {
|
||||
if (thumbnail) {
|
||||
setAssetId(data.thumbnail);
|
||||
} else if (data.resolutions && data.quality !== "original") {
|
||||
setAssetId(data.resolutions[data.quality]);
|
||||
} else {
|
||||
setAssetId(data.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAssetId();
|
||||
}, [data, thumbnail]);
|
||||
|
||||
const assetURL = useAssetURL(
|
||||
assetId,
|
||||
data?.type,
|
||||
defaultSources,
|
||||
unknownSource
|
||||
);
|
||||
return assetURL;
|
||||
}
|
||||
|
||||
export default AssetsContext;
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { useState, useEffect, useContext, SetStateAction } from "react";
|
||||
import shortid from "shortid";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
|
||||
import FakeStorage from "../helpers/FakeStorage";
|
||||
|
||||
type AuthContext = { userId: string; password: string; setPassword: React.Dispatch<any>; }
|
||||
type AuthContext = { password: string; setPassword: React.Dispatch<any> };
|
||||
|
||||
// TODO: check what default value we want here
|
||||
const AuthContext = React.createContext<AuthContext | undefined>(undefined);
|
||||
@@ -20,37 +17,16 @@ try {
|
||||
storage = new FakeStorage();
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: any }) {
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
|
||||
const [password, setPassword] = useState<string>(storage.getItem("auth") || "");
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [password, setPassword] = useState<string>(
|
||||
storage.getItem("auth") || ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
storage.setItem("auth", password);
|
||||
}, [password]);
|
||||
|
||||
// TODO: check pattern here -> undefined or empty default values
|
||||
const [userId, setUserId]: [ userId: string, setUserId: React.Dispatch<SetStateAction<string>> ] = useState("");
|
||||
useEffect(() => {
|
||||
if (!database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
async function loadUserId() {
|
||||
const storedUserId = await database?.table("user").get("userId");
|
||||
if (storedUserId) {
|
||||
setUserId(storedUserId.value);
|
||||
} else {
|
||||
const id = shortid.generate();
|
||||
setUserId(id);
|
||||
database?.table("user").add({ key: "userId", value: id });
|
||||
}
|
||||
}
|
||||
|
||||
loadUserId();
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
const value = {
|
||||
userId,
|
||||
password,
|
||||
setPassword,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useContext, SetStateAction } from "react";
|
||||
import Comlink, { Remote } from "comlink";
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import Dexie from "dexie";
|
||||
import * as Comlink from "comlink";
|
||||
|
||||
import ErrorBanner from "../components/banner/ErrorBanner";
|
||||
|
||||
@@ -7,30 +8,48 @@ import { getDatabase } from "../database";
|
||||
|
||||
//@ts-ignore
|
||||
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
|
||||
import Dexie from "dexie";
|
||||
|
||||
type DatabaseContext = { database: Dexie | undefined; databaseStatus: any; databaseError: Error | undefined; worker: Remote<any>; }
|
||||
type DatabaseContext = {
|
||||
database: Dexie | undefined;
|
||||
databaseStatus: any;
|
||||
databaseError: Error | undefined;
|
||||
worker: Comlink.Remote<any>;
|
||||
};
|
||||
|
||||
// TODO: check what default we want here
|
||||
const DatabaseContext = React.createContext< DatabaseContext | undefined>(undefined);
|
||||
const DatabaseContext =
|
||||
React.createContext<DatabaseContext | undefined>(undefined);
|
||||
|
||||
const worker = Comlink.wrap(new DatabaseWorker());
|
||||
|
||||
export function DatabaseProvider({ children }: { children: any}) {
|
||||
const [database, setDatabase]: [ database: Dexie | undefined, setDatabase: React.Dispatch<SetStateAction<Dexie | undefined>>] = useState();
|
||||
const [databaseStatus, setDatabaseStatus]: [ datebaseStatus: any, setDatabaseStatus: React.Dispatch<SetStateAction<string>>] = useState("loading");
|
||||
const [databaseError, setDatabaseError]: [ databaseError: Error | undefined, setDatabaseError: React.Dispatch<SetStateAction<Error | undefined>>] = useState();
|
||||
export function DatabaseProvider({ children }: { children: React.ReactNode }) {
|
||||
const [database, setDatabase] = useState<Dexie>();
|
||||
const [databaseStatus, setDatabaseStatus] =
|
||||
useState<"loading" | "disabled" | "upgrading" | "loaded">("loading");
|
||||
const [databaseError, setDatabaseError] = useState<Error>();
|
||||
|
||||
useEffect(() => {
|
||||
// Create a test database and open it to see if indexedDB is enabled
|
||||
let testDBRequest = window.indexedDB.open("__test");
|
||||
testDBRequest.onsuccess = async function () {
|
||||
testDBRequest.result.close();
|
||||
let db = getDatabase({ autoOpen: false });
|
||||
let db = getDatabase(
|
||||
{ autoOpen: false },
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
() => {
|
||||
setDatabaseStatus("upgrading");
|
||||
}
|
||||
);
|
||||
setDatabase(db);
|
||||
db.on("ready", () => {
|
||||
setDatabaseStatus("loaded");
|
||||
});
|
||||
db.on("versionchange", () => {
|
||||
// When another tab loads a new version of the database refresh the page
|
||||
window.location.reload();
|
||||
});
|
||||
await db.open();
|
||||
window.indexedDB.deleteDatabase("__test");
|
||||
};
|
||||
@@ -48,20 +67,35 @@ export function DatabaseProvider({ children }: { children: any}) {
|
||||
window.indexedDB.deleteDatabase("__test");
|
||||
};
|
||||
|
||||
function handleDatabaseError(event: any) {
|
||||
event.preventDefault();
|
||||
if (event.reason?.message.startsWith("QuotaExceededError")) {
|
||||
setDatabaseError({
|
||||
name: event.reason.name,
|
||||
message: "Storage Quota Exceeded Please Clear Space and Try Again.",
|
||||
});
|
||||
} else {
|
||||
setDatabaseError({
|
||||
name: event.reason.name,
|
||||
message: "Something went wrong, please refresh your browser.",
|
||||
});
|
||||
function handleDatabaseError(event: PromiseRejectionEvent) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
if (event.reason instanceof Dexie.DexieError) {
|
||||
if (event.reason?.inner?.name === "QuotaExceededError") {
|
||||
setDatabaseError({
|
||||
name: event.reason?.name,
|
||||
message:
|
||||
"Storage Quota Exceeded Please Clear Space and Try Again.",
|
||||
});
|
||||
} else if (event.reason?.inner?.name === "DatabaseClosedError") {
|
||||
setDatabaseError({
|
||||
name: event.reason?.name,
|
||||
message: "Database closed, please refresh your browser.",
|
||||
});
|
||||
} else {
|
||||
setDatabaseError({
|
||||
name: event.reason?.name,
|
||||
message: "Something went wrong, please refresh your browser.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setDatabaseError({
|
||||
name: event.reason?.name,
|
||||
message: "Something went wrong, please refresh your browser.",
|
||||
});
|
||||
}
|
||||
console.error(event.reason);
|
||||
}
|
||||
console.error(event.reason);
|
||||
}
|
||||
window.addEventListener("unhandledrejection", handleDatabaseError);
|
||||
|
||||
|
||||
75
src/contexts/DragContext.js
Normal file
75
src/contexts/DragContext.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import React, { useRef, ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
useDndContext,
|
||||
useDndMonitor,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
/**
|
||||
* Wrap a dnd-kit DndContext with a position monitor to get the
|
||||
* active drag element on drag end
|
||||
* TODO: use look into fixing this upstream
|
||||
* Related: https://github.com/clauderic/dnd-kit/issues/238
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DragEndOverlayEvent
|
||||
* @property {DOMRect} overlayNodeClientRect
|
||||
*
|
||||
* @typedef {DragEndEvent & DragEndOverlayEvent} DragEndWithOverlayProps
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback DragEndWithOverlayEvent
|
||||
* @param {DragEndWithOverlayProps} props
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef CustomDragProps
|
||||
* @property {DragEndWithOverlayEvent=} onDragEnd
|
||||
* @property {ReactNode} children
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {CustomDragProps} props
|
||||
*/
|
||||
function DragPositionMonitor({ children, onDragEnd }) {
|
||||
const { overlayNode } = useDndContext();
|
||||
|
||||
const overlayNodeClientRectRef = useRef();
|
||||
function handleDragMove() {
|
||||
if (overlayNode?.nodeRef?.current) {
|
||||
overlayNodeClientRectRef.current = overlayNode.nodeRef.current.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(props) {
|
||||
onDragEnd &&
|
||||
onDragEnd({
|
||||
...props,
|
||||
overlayNodeClientRect: overlayNodeClientRectRef.current,
|
||||
});
|
||||
}
|
||||
useDndMonitor({ onDragEnd: handleDragEnd, onDragMove: handleDragMove });
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Import Props interface from dnd-kit with conversion to Typescript
|
||||
* @param {CustomDragProps} props
|
||||
*/
|
||||
function DragContext({ children, onDragEnd, ...props }) {
|
||||
return (
|
||||
<DndContext {...props}>
|
||||
<DragPositionMonitor onDragEnd={onDragEnd}>
|
||||
{children}
|
||||
</DragPositionMonitor>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragContext;
|
||||
240
src/contexts/GroupContext.js
Normal file
240
src/contexts/GroupContext.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import { useKeyboard, useBlur } from "./KeyboardContext";
|
||||
|
||||
import { getGroupItems, groupsFromIds } from "../helpers/group";
|
||||
|
||||
import shortcuts from "../shortcuts";
|
||||
|
||||
const GroupContext = React.createContext();
|
||||
|
||||
export function GroupProvider({
|
||||
groups,
|
||||
itemNames,
|
||||
onGroupsChange,
|
||||
onGroupsSelect,
|
||||
disabled,
|
||||
children,
|
||||
}) {
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
||||
// Either single, multiple or range
|
||||
const [selectMode, setSelectMode] = useState("single");
|
||||
|
||||
/**
|
||||
* Group Open
|
||||
*/
|
||||
const [openGroupId, setOpenGroupId] = useState();
|
||||
const [openGroupItems, setOpenGroupItems] = useState([]);
|
||||
useEffect(() => {
|
||||
if (openGroupId) {
|
||||
const openGroups = groupsFromIds([openGroupId], groups);
|
||||
if (openGroups.length === 1) {
|
||||
const openGroup = openGroups[0];
|
||||
setOpenGroupItems(getGroupItems(openGroup));
|
||||
} else {
|
||||
// Close group if we can't find it
|
||||
// This can happen if it was deleted or all it's items were deleted
|
||||
setOpenGroupItems([]);
|
||||
setOpenGroupId();
|
||||
}
|
||||
} else {
|
||||
setOpenGroupItems([]);
|
||||
}
|
||||
}, [openGroupId, groups]);
|
||||
|
||||
function handleGroupOpen(groupId) {
|
||||
setSelectedGroupIds([]);
|
||||
setOpenGroupId(groupId);
|
||||
}
|
||||
|
||||
function handleGroupClose() {
|
||||
setSelectedGroupIds([]);
|
||||
setOpenGroupId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search
|
||||
*/
|
||||
const [filter, setFilter] = useState();
|
||||
const [filteredGroupItems, setFilteredGroupItems] = useState([]);
|
||||
const [fuse, setFuse] = useState();
|
||||
// Update search index when items change
|
||||
useEffect(() => {
|
||||
let items = [];
|
||||
for (let group of groups) {
|
||||
const itemsToAdd = getGroupItems(group);
|
||||
const namedItems = itemsToAdd.map((item) => ({
|
||||
...item,
|
||||
name: itemNames[item.id],
|
||||
}));
|
||||
items.push(...namedItems);
|
||||
}
|
||||
setFuse(new Fuse(items, { keys: ["name"] }));
|
||||
}, [groups, itemNames]);
|
||||
|
||||
// Perform search when search changes
|
||||
useEffect(() => {
|
||||
if (filter) {
|
||||
const query = fuse.search(filter);
|
||||
setFilteredGroupItems(query.map((result) => result.item));
|
||||
setOpenGroupId();
|
||||
} else {
|
||||
setFilteredGroupItems([]);
|
||||
}
|
||||
}, [filter, fuse]);
|
||||
|
||||
/**
|
||||
* Handlers
|
||||
*/
|
||||
|
||||
const activeGroups = openGroupId
|
||||
? openGroupItems
|
||||
: filter
|
||||
? filteredGroupItems
|
||||
: groups;
|
||||
|
||||
/**
|
||||
* @param {string|undefined} groupId The group to apply changes to, leave undefined to replace the full group object
|
||||
*/
|
||||
function handleGroupsChange(newGroups, groupId) {
|
||||
if (groupId) {
|
||||
// If a group is specidifed then update that group with the new items
|
||||
const groupIndex = groups.findIndex((group) => group.id === groupId);
|
||||
let updatedGroups = cloneDeep(groups);
|
||||
const group = updatedGroups[groupIndex];
|
||||
updatedGroups[groupIndex] = { ...group, items: newGroups };
|
||||
onGroupsChange(updatedGroups);
|
||||
} else {
|
||||
onGroupsChange(newGroups);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupSelect(groupId) {
|
||||
let groupIds = [];
|
||||
if (groupId) {
|
||||
switch (selectMode) {
|
||||
case "single":
|
||||
groupIds = [groupId];
|
||||
break;
|
||||
case "multiple":
|
||||
if (selectedGroupIds.includes(groupId)) {
|
||||
groupIds = selectedGroupIds.filter((id) => id !== groupId);
|
||||
} else {
|
||||
groupIds = [...selectedGroupIds, groupId];
|
||||
}
|
||||
break;
|
||||
case "range":
|
||||
if (selectedGroupIds.length > 0) {
|
||||
const currentIndex = activeGroups.findIndex(
|
||||
(g) => g.id === groupId
|
||||
);
|
||||
const lastIndex = activeGroups.findIndex(
|
||||
(g) => g.id === selectedGroupIds[selectedGroupIds.length - 1]
|
||||
);
|
||||
let idsToAdd = [];
|
||||
let idsToRemove = [];
|
||||
const direction = currentIndex > lastIndex ? 1 : -1;
|
||||
for (
|
||||
let i = lastIndex + direction;
|
||||
direction < 0 ? i >= currentIndex : i <= currentIndex;
|
||||
i += direction
|
||||
) {
|
||||
const id = activeGroups[i].id;
|
||||
if (selectedGroupIds.includes(id)) {
|
||||
idsToRemove.push(id);
|
||||
} else {
|
||||
idsToAdd.push(id);
|
||||
}
|
||||
}
|
||||
groupIds = [...selectedGroupIds, ...idsToAdd].filter(
|
||||
(id) => !idsToRemove.includes(id)
|
||||
);
|
||||
} else {
|
||||
groupIds = [groupId];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
groupIds = [];
|
||||
}
|
||||
}
|
||||
setSelectedGroupIds(groupIds);
|
||||
onGroupsSelect(groupIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcuts
|
||||
*/
|
||||
function handleKeyDown(event) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event)) {
|
||||
setSelectMode("range");
|
||||
}
|
||||
if (shortcuts.selectMultiple(event)) {
|
||||
setSelectMode("multiple");
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event) && selectMode === "range") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKeyDown, handleKeyUp);
|
||||
|
||||
// Set select mode to single when cmd+tabing
|
||||
function handleBlur() {
|
||||
setSelectMode("single");
|
||||
}
|
||||
|
||||
useBlur(handleBlur);
|
||||
|
||||
const value = {
|
||||
groups,
|
||||
activeGroups,
|
||||
openGroupId,
|
||||
openGroupItems,
|
||||
filter,
|
||||
filteredGroupItems,
|
||||
selectedGroupIds,
|
||||
selectMode,
|
||||
onSelectModeChange: setSelectMode,
|
||||
onGroupOpen: handleGroupOpen,
|
||||
onGroupClose: handleGroupClose,
|
||||
onGroupsChange: handleGroupsChange,
|
||||
onGroupSelect: handleGroupSelect,
|
||||
onFilterChange: setFilter,
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={value}>{children}</GroupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
GroupProvider.defaultProps = {
|
||||
groups: [],
|
||||
itemNames: {},
|
||||
onGroupsChange: () => {},
|
||||
onGroupsSelect: () => {},
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export function useGroup() {
|
||||
const context = useContext(GroupContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useGroup must be used within a GroupProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default GroupContext;
|
||||
@@ -3,189 +3,140 @@ import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useCallback,
|
||||
useRef,
|
||||
ReactChild,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import * as Comlink from "comlink";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
|
||||
import { maps as defaultMaps } from "../maps";
|
||||
import { Map, MapState, Note, TokenState } from "../components/map/Map";
|
||||
import { Fog } from "../helpers/drawing";
|
||||
import { Map, MapState, Note } from "../components/map/Map";
|
||||
|
||||
import { removeGroupsItems } from "../helpers/group";
|
||||
|
||||
// TODO: fix differences in types between default maps and imported maps
|
||||
type MapDataContext = {
|
||||
maps: Array<Map>,
|
||||
ownedMaps: Array<Map>
|
||||
mapStates: MapState[],
|
||||
addMap: (map: Map) => void,
|
||||
removeMap: (id: string) => void,
|
||||
removeMaps: (ids: string[]) => void,
|
||||
resetMap: (id: string) => void,
|
||||
updateMap: (id: string, update: Partial<Map>) => void,
|
||||
updateMaps: (ids: string[], update: Partial<Map>) => void,
|
||||
updateMapState: (id: string, update: Partial<MapState>) => void,
|
||||
putMap: (map: Map) => void,
|
||||
getMap: (id: string) => Map | undefined,
|
||||
getMapFromDB: (id: string) => Promise<Map>,
|
||||
mapsLoading: boolean,
|
||||
getMapStateFromDB: (id: string) => Promise<MapState>,
|
||||
}
|
||||
maps: Array<Map>;
|
||||
mapStates: MapState[];
|
||||
addMap: (map: Map) => void;
|
||||
removeMaps: (ids: string[]) => void;
|
||||
resetMap: (id: string) => void;
|
||||
updateMap: (id: string, update: Partial<Map>) => void;
|
||||
updateMapState: (id: string, update: Partial<MapState>) => void;
|
||||
getMapState: (id: string) => Promise<MapState>;
|
||||
getMap: (id: string) => Promise<Map | undefined>;
|
||||
mapsLoading: boolean;
|
||||
updateMapGroups: (groups: any) => void;
|
||||
mapsById: Record<string, Map>;
|
||||
mapGroups: any[];
|
||||
};
|
||||
|
||||
const MapDataContext = React.createContext<MapDataContext | undefined>(undefined);
|
||||
const MapDataContext =
|
||||
React.createContext<MapDataContext | undefined>(undefined);
|
||||
|
||||
// Maximum number of maps to keep in the cache
|
||||
const cachedMapMax = 15;
|
||||
|
||||
const defaultMapState: MapState = {
|
||||
mapId: "",
|
||||
tokens: {} as Record<string, TokenState>,
|
||||
drawShapes: {} as any,
|
||||
fogShapes: {} as Fog[],
|
||||
const defaultMapState = {
|
||||
tokens: {},
|
||||
drawShapes: {},
|
||||
fogShapes: {},
|
||||
// Flags to determine what other people can edit
|
||||
editFlags: ["drawing", "tokens", "notes", "fog"],
|
||||
notes: {} as Note[],
|
||||
};
|
||||
|
||||
export function MapDataProvider({ children }: { children: ReactChild }) {
|
||||
const { database, databaseStatus, worker } = useDatabase();
|
||||
const { userId } = useAuth();
|
||||
export function MapDataProvider({ children }: { children: React.ReactNode }) {
|
||||
const { database } = useDatabase();
|
||||
|
||||
const [maps, setMaps] = useState<Array<Map>>([]);
|
||||
const [mapStates, setMapStates] = useState<MapState[]>([]);
|
||||
const [mapsLoading, setMapsLoading] = useState<boolean>(true);
|
||||
const mapsQuery = useLiveQuery<Map[]>(
|
||||
() => database?.table("maps").toArray() || [],
|
||||
[database]
|
||||
);
|
||||
const mapStatesQuery = useLiveQuery<MapState[]>(
|
||||
() => database?.table("states").toArray() || [],
|
||||
[database]
|
||||
);
|
||||
|
||||
// Load maps from the database and ensure state is properly seup
|
||||
const maps = useMemo(() => mapsQuery || [], [mapsQuery]);
|
||||
const mapStates = useMemo(() => mapStatesQuery || [], [mapStatesQuery]);
|
||||
const mapsLoading = useMemo(
|
||||
() => !mapsQuery || !mapStatesQuery,
|
||||
[mapsQuery, mapStatesQuery]
|
||||
);
|
||||
|
||||
const mapGroupQuery = useLiveQuery(
|
||||
() => database?.table("groups").get("maps"),
|
||||
[database]
|
||||
);
|
||||
|
||||
const [mapGroups, setMapGroups] = useState([]);
|
||||
useEffect(() => {
|
||||
if (!userId || !database || databaseStatus === "loading") {
|
||||
return;
|
||||
async function updateMapGroups() {
|
||||
const group = await database?.table("groups").get("maps");
|
||||
setMapGroups(group.items);
|
||||
}
|
||||
async function getDefaultMaps(): Promise<Map[]> {
|
||||
const defaultMapsWithIds: Array<Map> = [];
|
||||
for (let i = 0; i < defaultMaps.length; i++) {
|
||||
const defaultMap = defaultMaps[i];
|
||||
const mapId = `__default-${defaultMap.name}`;
|
||||
defaultMapsWithIds.push({
|
||||
...defaultMap,
|
||||
lastUsed: Date.now() + i,
|
||||
id: mapId,
|
||||
owner: userId,
|
||||
// Emulate the time increasing to avoid sort errors
|
||||
created: Date.now() + i,
|
||||
lastModified: Date.now() + i,
|
||||
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(mapId);
|
||||
if (!state) {
|
||||
await database?.table("states").add({ ...defaultMapState, mapId: mapId });
|
||||
}
|
||||
}
|
||||
return defaultMapsWithIds;
|
||||
if (database && mapGroupQuery) {
|
||||
updateMapGroups();
|
||||
}
|
||||
}, [mapGroupQuery, database]);
|
||||
|
||||
// Loads maps without the file data to save memory
|
||||
async function loadMaps() {
|
||||
let storedMaps: Map[] = [];
|
||||
// Try to load maps with worker, fallback to database if failed
|
||||
const packedMaps = await worker.loadData("maps");
|
||||
// let packedMaps;
|
||||
if (packedMaps) {
|
||||
storedMaps = decode(packedMaps) as Map[];
|
||||
} else {
|
||||
console.warn("Unable to load maps with worker, loading may be slow");
|
||||
await database?.table("maps").each((map) => {
|
||||
const { file, resolutions, ...rest } = map;
|
||||
storedMaps.push(rest);
|
||||
});
|
||||
}
|
||||
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
|
||||
const defaultMapsWithIds = await getDefaultMaps();
|
||||
const allMaps: Array<Map> = [...sortedMaps, ...defaultMapsWithIds];
|
||||
setMaps(allMaps);
|
||||
const storedStates = await database?.table("states").toArray() as MapState[];
|
||||
setMapStates(storedStates);
|
||||
setMapsLoading(false);
|
||||
}
|
||||
|
||||
loadMaps();
|
||||
}, [userId, database, databaseStatus, worker]);
|
||||
|
||||
const mapsRef = useRef(maps);
|
||||
useEffect(() => {
|
||||
mapsRef.current = maps;
|
||||
}, [maps]);
|
||||
|
||||
const getMap = useCallback((mapId) => {
|
||||
return mapsRef.current.find((map) => map.id === mapId);
|
||||
}, []);
|
||||
|
||||
const getMapFromDB = useCallback(
|
||||
async (mapId) => {
|
||||
let map = await database?.table("maps").get(mapId) as Map;
|
||||
const getMap = useCallback(
|
||||
async (mapId: string) => {
|
||||
let map = (await database?.table("maps").get(mapId)) as Map;
|
||||
return map;
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const getMapStateFromDB = useCallback(
|
||||
const getMapState = useCallback(
|
||||
async (mapId) => {
|
||||
let mapState = await database?.table("states").get(mapId) as MapState;
|
||||
let mapState = (await database?.table("states").get(mapId)) as MapState;
|
||||
return mapState;
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep up to cachedMapMax amount of maps that you don't own
|
||||
* Sorted by when they we're last used
|
||||
*/
|
||||
const updateCache = useCallback(async () => {
|
||||
const cachedMaps = await database?.table("maps").where("owner").notEqual(userId).sortBy("lastUsed") as Map[];
|
||||
if (cachedMaps.length > cachedMapMax) {
|
||||
const cacheDeleteCount = cachedMaps.length - cachedMapMax;
|
||||
const idsToDelete = cachedMaps
|
||||
.slice(0, cacheDeleteCount)
|
||||
.map((map: Map) => map.id);
|
||||
database?.table("maps").where("id").anyOf(idsToDelete).delete();
|
||||
}
|
||||
}, [database, userId]);
|
||||
|
||||
/**
|
||||
* Adds a map to the database, also adds an assosiated state for that map
|
||||
* @param {Map} map map to add
|
||||
* Adds a map to the database, also adds an assosiated state and group for that map
|
||||
* @param {Object} map map to add
|
||||
*/
|
||||
const addMap = useCallback(
|
||||
async (map) => {
|
||||
// Just update map database as react state will be updated with an Observable
|
||||
const state = { ...defaultMapState, mapId: map.id };
|
||||
await database?.table("maps").add(map);
|
||||
await database?.table("states").add(state);
|
||||
if (map.owner !== userId) {
|
||||
await updateCache();
|
||||
if (database) {
|
||||
// Just update map database as react state will be updated with an Observable
|
||||
const state = { ...defaultMapState, mapId: map.id };
|
||||
await database.table("maps").add(map);
|
||||
await database.table("states").add(state);
|
||||
const group = await database.table("groups").get("maps");
|
||||
await database.table("groups").update("maps", {
|
||||
items: [{ id: map.id, type: "item" }, ...group.items],
|
||||
});
|
||||
}
|
||||
},
|
||||
[database, updateCache, userId]
|
||||
);
|
||||
|
||||
const removeMap = useCallback(
|
||||
async (id) => {
|
||||
await database?.table("maps").delete(id);
|
||||
await database?.table("states").delete(id);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const removeMaps = useCallback(
|
||||
async (ids) => {
|
||||
await database?.table("maps").bulkDelete(ids);
|
||||
await database?.table("states").bulkDelete(ids);
|
||||
if (database) {
|
||||
const maps = await database.table("maps").bulkGet(ids);
|
||||
// Remove assets linked with maps
|
||||
let assetIds = [];
|
||||
for (let map of maps) {
|
||||
if (map.type === "file") {
|
||||
assetIds.push(map.file);
|
||||
assetIds.push(map.thumbnail);
|
||||
for (let res of Object.values(map.resolutions)) {
|
||||
assetIds.push(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const group = await database.table("groups").get("maps");
|
||||
let items = removeGroupsItems(group.items, ids);
|
||||
await database.table("groups").update("maps", { items });
|
||||
|
||||
await database.table("maps").bulkDelete(ids);
|
||||
await database.table("states").bulkDelete(ids);
|
||||
await database.table("assets").bulkDelete(assetIds);
|
||||
}
|
||||
},
|
||||
[database]
|
||||
);
|
||||
@@ -201,23 +152,7 @@ export function MapDataProvider({ children }: { children: ReactChild }) {
|
||||
|
||||
const updateMap = useCallback(
|
||||
async (id, update) => {
|
||||
// fake-indexeddb throws an error when updating maps in production.
|
||||
// Catch that error and use put when it fails
|
||||
try {
|
||||
await database?.table("maps").update(id, update);
|
||||
} catch (error) {
|
||||
const map = (await getMapFromDB(id)) || {};
|
||||
await database?.table("maps").put({ ...map, id, ...update });
|
||||
}
|
||||
},
|
||||
[database, getMapFromDB]
|
||||
);
|
||||
|
||||
const updateMaps = useCallback(
|
||||
async (ids, update) => {
|
||||
await Promise.all(
|
||||
ids.map((id: string) => database?.table("maps").update(id, update))
|
||||
);
|
||||
await database?.table("maps").update(id, update);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
@@ -229,112 +164,39 @@ export function MapDataProvider({ children }: { children: ReactChild }) {
|
||||
[database]
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a map to the database if none exists or replaces a map if it already exists
|
||||
* Note: this does not add a map state to do that use AddMap
|
||||
* @param {Object} map the map to put
|
||||
*/
|
||||
const putMap = useCallback(
|
||||
async (map) => {
|
||||
// Attempt to use worker to put map to avoid UI lockup
|
||||
const packedMap = encode(map);
|
||||
const success = await worker.putData(
|
||||
Comlink.transfer(packedMap, [packedMap.buffer]),
|
||||
"maps",
|
||||
false
|
||||
);
|
||||
if (!success) {
|
||||
await database?.table("maps").put(map);
|
||||
}
|
||||
if (map.owner !== userId) {
|
||||
await updateCache();
|
||||
}
|
||||
const updateMapGroups = useCallback(
|
||||
async (groups) => {
|
||||
// Update group state immediately to avoid animation delay
|
||||
setMapGroups(groups);
|
||||
await database?.table("groups").update("maps", { items: groups });
|
||||
},
|
||||
[database, updateCache, userId, worker]
|
||||
[database]
|
||||
);
|
||||
|
||||
// Create DB observable to sync creating and deleting
|
||||
const [mapsById, setMapsById] = useState<Record<string, Map>>({});
|
||||
useEffect(() => {
|
||||
if (!database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleMapChanges(changes: any) {
|
||||
for (let change of changes) {
|
||||
if (change.table === "maps") {
|
||||
if (change.type === 1) {
|
||||
// Created
|
||||
const map: Map = change.obj;
|
||||
const state: MapState = { ...defaultMapState, mapId: map.id };
|
||||
setMaps((prevMaps) => [map, ...prevMaps]);
|
||||
setMapStates((prevStates) => [state, ...prevStates]);
|
||||
} else if (change.type === 2) {
|
||||
const map = change.obj;
|
||||
setMaps((prevMaps) => {
|
||||
const newMaps = [...prevMaps];
|
||||
const i = newMaps.findIndex((m) => m.id === map.id);
|
||||
if (i > -1) {
|
||||
newMaps[i] = map;
|
||||
}
|
||||
return newMaps;
|
||||
});
|
||||
} else if (change.type === 3) {
|
||||
// Deleted
|
||||
const id = change.key;
|
||||
setMaps((prevMaps) => {
|
||||
const filtered = prevMaps.filter((map) => map.id !== id);
|
||||
return filtered;
|
||||
});
|
||||
setMapStates((prevMapsStates) => {
|
||||
const filtered = prevMapsStates.filter(
|
||||
(state) => state.mapId !== id
|
||||
);
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (change.table === "states") {
|
||||
if (change.type === 2) {
|
||||
// Update map state
|
||||
const state = change.obj;
|
||||
setMapStates((prevMapStates) => {
|
||||
const newStates = [...prevMapStates];
|
||||
const i = newStates.findIndex((s) => s.mapId === state.mapId);
|
||||
if (i > -1) {
|
||||
newStates[i] = state;
|
||||
}
|
||||
return newStates;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database.on("changes", handleMapChanges);
|
||||
|
||||
return () => {
|
||||
database.on("changes").unsubscribe(handleMapChanges);
|
||||
};
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
const ownedMaps = maps.filter((map) => map.owner === userId);
|
||||
setMapsById(
|
||||
maps.reduce((obj: Record<string, Map>, map) => {
|
||||
obj[map.id] = map;
|
||||
return obj;
|
||||
}, {})
|
||||
);
|
||||
}, [maps]);
|
||||
|
||||
const value = {
|
||||
maps,
|
||||
ownedMaps,
|
||||
mapStates,
|
||||
mapGroups,
|
||||
addMap,
|
||||
removeMap,
|
||||
removeMaps,
|
||||
resetMap,
|
||||
updateMap,
|
||||
updateMaps,
|
||||
updateMapState,
|
||||
putMap,
|
||||
getMap,
|
||||
getMapFromDB,
|
||||
mapsLoading,
|
||||
getMapStateFromDB,
|
||||
getMapState,
|
||||
updateMapGroups,
|
||||
mapsById,
|
||||
};
|
||||
return (
|
||||
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
||||
|
||||
@@ -1,46 +1,65 @@
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { omit, isEmpty } from "../helpers/shared";
|
||||
import React, { useState, useRef, useContext, useCallback } from "react";
|
||||
|
||||
const MapLoadingContext = React.createContext<any | undefined>(undefined);
|
||||
type MapLoadingProgress = {
|
||||
count: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export function MapLoadingProvider({ children }: { children: any}) {
|
||||
const [loadingAssetCount, setLoadingAssetCount] = useState(0);
|
||||
type MapLoadingProgressUpdate = MapLoadingProgress & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function assetLoadStart() {
|
||||
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets + 1);
|
||||
}
|
||||
type MapLoadingContext = {
|
||||
isLoading: boolean;
|
||||
assetLoadStart: (id: string) => void;
|
||||
assetProgressUpdate: (update: MapLoadingProgressUpdate) => void;
|
||||
loadingProgressRef: React.MutableRefObject<number | null>;
|
||||
};
|
||||
|
||||
function assetLoadFinish() {
|
||||
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
|
||||
}
|
||||
const MapLoadingContext =
|
||||
React.createContext<MapLoadingContext | undefined>(undefined);
|
||||
|
||||
const assetProgressRef = useRef<any>({});
|
||||
export function MapLoadingProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Mapping from asset id to the count and total number of pieces loaded
|
||||
const assetProgressRef = useRef<Record<string, MapLoadingProgress>>({});
|
||||
// Loading progress of all assets between 0 and 1
|
||||
const loadingProgressRef = useRef<number | null>(null);
|
||||
function assetProgressUpdate({ id, count, total }: { id: string, count: number, total: number }) {
|
||||
if (count === total) {
|
||||
assetProgressRef.current = omit(assetProgressRef.current, [id]);
|
||||
} else {
|
||||
assetProgressRef.current = {
|
||||
...assetProgressRef.current,
|
||||
[id]: { count, total },
|
||||
};
|
||||
}
|
||||
if (!isEmpty(assetProgressRef.current)) {
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (let progress of Object.values(assetProgressRef.current) as any) {
|
||||
total += progress.total;
|
||||
count += progress.count;
|
||||
}
|
||||
loadingProgressRef.current = count / total;
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = loadingAssetCount > 0;
|
||||
const assetLoadStart = useCallback((id) => {
|
||||
setIsLoading(true);
|
||||
// Add asset at a 0% progress
|
||||
assetProgressRef.current = {
|
||||
...assetProgressRef.current,
|
||||
[id]: { count: 0, total: 1 },
|
||||
};
|
||||
}, []);
|
||||
|
||||
const assetProgressUpdate = useCallback(({ id, count, total }) => {
|
||||
assetProgressRef.current = {
|
||||
...assetProgressRef.current,
|
||||
[id]: { count, total },
|
||||
};
|
||||
// Update loading progress
|
||||
let complete = 0;
|
||||
const progresses = Object.values(assetProgressRef.current);
|
||||
for (let progress of progresses) {
|
||||
complete += progress.count / progress.total;
|
||||
}
|
||||
loadingProgressRef.current = complete / progresses.length;
|
||||
// All loading is complete
|
||||
if (loadingProgressRef.current === 1) {
|
||||
setIsLoading(false);
|
||||
assetProgressRef.current = {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
assetLoadStart,
|
||||
assetLoadFinish,
|
||||
isLoading,
|
||||
assetProgressUpdate,
|
||||
loadingProgressRef,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useContext } from "react";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useUserId } from "./UserIdContext";
|
||||
|
||||
import { getRandomMonster } from "../helpers/monsters";
|
||||
|
||||
@@ -12,8 +12,14 @@ import { PlayerInfo } from "../components/party/PartyState";
|
||||
export const PlayerStateContext = React.createContext<any>(undefined);
|
||||
export const PlayerUpdaterContext = React.createContext<any>(() => {});
|
||||
|
||||
export function PlayerProvider({ session, children }: { session: Session, children: any}) {
|
||||
const { userId } = useAuth();
|
||||
export function PlayerProvider({
|
||||
session,
|
||||
children,
|
||||
}: {
|
||||
session: Session;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const userId = useUserId();
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
|
||||
const [playerState, setPlayerState] = useNetworkedState(
|
||||
@@ -55,7 +61,7 @@ export function PlayerProvider({ session, children }: { session: Session, childr
|
||||
if (
|
||||
playerState.nickname &&
|
||||
database !== undefined &&
|
||||
databaseStatus !== "loading"
|
||||
(databaseStatus === "loaded" || databaseStatus === "disabled")
|
||||
) {
|
||||
database
|
||||
.table("user")
|
||||
|
||||
259
src/contexts/TileDragContext.js
Normal file
259
src/contexts/TileDragContext.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import {
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
import DragContext from "./DragContext";
|
||||
|
||||
import { useGroup } from "./GroupContext";
|
||||
|
||||
import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
|
||||
|
||||
import usePreventSelect from "../hooks/usePreventSelect";
|
||||
|
||||
const TileDragIdContext = React.createContext();
|
||||
const TileOverGroupIdContext = React.createContext();
|
||||
const TileDragCursorContext = React.createContext();
|
||||
|
||||
export const BASE_SORTABLE_ID = "__base__";
|
||||
export const GROUP_SORTABLE_ID = "__group__";
|
||||
export const GROUP_ID_PREFIX = "__group__";
|
||||
export const UNGROUP_ID = "__ungroup__";
|
||||
export const ADD_TO_MAP_ID = "__add__";
|
||||
|
||||
// Custom rectIntersect that takes a point
|
||||
function rectIntersection(rects, point) {
|
||||
for (let rect of rects) {
|
||||
const [id, bounds] = rect;
|
||||
if (
|
||||
id &&
|
||||
bounds &&
|
||||
point.x > bounds.offsetLeft &&
|
||||
point.x < bounds.offsetLeft + bounds.width &&
|
||||
point.y > bounds.offsetTop &&
|
||||
point.y < bounds.offsetTop + bounds.height
|
||||
) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TileDragProvider({
|
||||
onDragAdd,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
children,
|
||||
}) {
|
||||
const {
|
||||
groups,
|
||||
activeGroups,
|
||||
openGroupId,
|
||||
selectedGroupIds,
|
||||
onGroupsChange,
|
||||
onGroupSelect,
|
||||
filter,
|
||||
} = useGroup();
|
||||
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 3 },
|
||||
});
|
||||
const touchSensor = useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 250, tolerance: 5 },
|
||||
});
|
||||
const keyboardSensor = useSensor(KeyboardSensor);
|
||||
|
||||
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
|
||||
|
||||
const [dragId, setDragId] = useState(null);
|
||||
const [overId, setOverId] = useState(null);
|
||||
const [dragCursor, setDragCursor] = useState("pointer");
|
||||
|
||||
const [preventSelect, resumeSelect] = usePreventSelect();
|
||||
|
||||
const [overGroupId, setOverGroupId] = useState(null);
|
||||
useEffect(() => {
|
||||
setOverGroupId(
|
||||
(overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null
|
||||
);
|
||||
}, [overId]);
|
||||
|
||||
function handleDragStart(event) {
|
||||
const { active, over } = event;
|
||||
setDragId(active.id);
|
||||
setOverId(over?.id || null);
|
||||
if (!selectedGroupIds.includes(active.id)) {
|
||||
onGroupSelect(active.id);
|
||||
}
|
||||
setDragCursor("grabbing");
|
||||
|
||||
onDragStart && onDragStart(event);
|
||||
|
||||
preventSelect();
|
||||
}
|
||||
|
||||
function handleDragOver(event) {
|
||||
const { over } = event;
|
||||
|
||||
setOverId(over?.id || null);
|
||||
if (over) {
|
||||
if (
|
||||
over.id.startsWith(UNGROUP_ID) ||
|
||||
over.id.startsWith(GROUP_ID_PREFIX)
|
||||
) {
|
||||
setDragCursor("alias");
|
||||
} else if (over.id.startsWith(ADD_TO_MAP_ID)) {
|
||||
setDragCursor(onDragAdd ? "copy" : "no-drop");
|
||||
} else {
|
||||
setDragCursor("grabbing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(event) {
|
||||
const { active, over, overlayNodeClientRect } = event;
|
||||
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
setDragCursor("pointer");
|
||||
if (active && over && active.id !== over.id) {
|
||||
let selectedIndices = selectedGroupIds.map((groupId) =>
|
||||
activeGroups.findIndex((group) => group.id === groupId)
|
||||
);
|
||||
// Maintain current group sorting
|
||||
selectedIndices = selectedIndices.sort((a, b) => a - b);
|
||||
|
||||
if (over.id.startsWith(GROUP_ID_PREFIX)) {
|
||||
onGroupSelect();
|
||||
// Handle tile group
|
||||
const overId = over.id.slice(9);
|
||||
if (overId !== active.id) {
|
||||
const overGroupIndex = activeGroups.findIndex(
|
||||
(group) => group.id === overId
|
||||
);
|
||||
onGroupsChange(
|
||||
moveGroupsInto(activeGroups, overGroupIndex, selectedIndices),
|
||||
openGroupId
|
||||
);
|
||||
}
|
||||
} else if (over.id === UNGROUP_ID) {
|
||||
onGroupSelect();
|
||||
// Handle tile ungroup
|
||||
const newGroups = ungroup(groups, openGroupId, selectedIndices);
|
||||
onGroupsChange(newGroups);
|
||||
} else if (over.id === ADD_TO_MAP_ID) {
|
||||
onDragAdd &&
|
||||
overlayNodeClientRect &&
|
||||
onDragAdd(selectedGroupIds, overlayNodeClientRect);
|
||||
} else if (!filter) {
|
||||
// Hanlde tile move only if we have no filter
|
||||
const overGroupIndex = activeGroups.findIndex(
|
||||
(group) => group.id === over.id
|
||||
);
|
||||
onGroupsChange(
|
||||
moveGroups(activeGroups, overGroupIndex, selectedIndices),
|
||||
openGroupId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resumeSelect();
|
||||
|
||||
onDragEnd && onDragEnd(event);
|
||||
}
|
||||
|
||||
function handleDragCancel(event) {
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
setDragCursor("pointer");
|
||||
|
||||
resumeSelect();
|
||||
|
||||
onDragCancel && onDragCancel(event);
|
||||
}
|
||||
|
||||
function customCollisionDetection(rects, rect) {
|
||||
const rectCenter = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
};
|
||||
|
||||
// Find whether out rect center is outside our add to map rect
|
||||
const addRect = rects.find(([id]) => id === ADD_TO_MAP_ID);
|
||||
if (addRect) {
|
||||
const intersectingAddRect = rectIntersection([addRect], rectCenter);
|
||||
if (!intersectingAddRect) {
|
||||
return ADD_TO_MAP_ID;
|
||||
}
|
||||
}
|
||||
|
||||
// Find whether out rect center is outside our ungroup rect
|
||||
if (openGroupId) {
|
||||
const ungroupRect = rects.find(([id]) => id === UNGROUP_ID);
|
||||
if (ungroupRect) {
|
||||
const intersectingGroupRect = rectIntersection(
|
||||
[ungroupRect],
|
||||
rectCenter
|
||||
);
|
||||
if (!intersectingGroupRect) {
|
||||
return UNGROUP_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const otherRects = rects.filter(
|
||||
([id]) => id !== ADD_TO_MAP_ID && id !== UNGROUP_ID
|
||||
);
|
||||
|
||||
return closestCenter(otherRects, rect);
|
||||
}
|
||||
|
||||
return (
|
||||
<DragContext
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragCancel={handleDragCancel}
|
||||
sensors={sensors}
|
||||
collisionDetection={customCollisionDetection}
|
||||
>
|
||||
<TileDragIdContext.Provider value={dragId}>
|
||||
<TileOverGroupIdContext.Provider value={overGroupId}>
|
||||
<TileDragCursorContext.Provider value={dragCursor}>
|
||||
{children}
|
||||
</TileDragCursorContext.Provider>
|
||||
</TileOverGroupIdContext.Provider>
|
||||
</TileDragIdContext.Provider>
|
||||
</DragContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTileDragId() {
|
||||
const context = useContext(TileDragIdContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTileDrag must be used within a TileDragProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTileOverGroupId() {
|
||||
const context = useContext(TileOverGroupIdContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTileDrag must be used within a TileDragProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTileDragCursor() {
|
||||
const context = useContext(TileDragCursorContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTileDrag must be used within a TileDragProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -3,96 +3,59 @@ import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
|
||||
import { DefaultToken, FileToken, Token, tokens as defaultTokens } from "../tokens";
|
||||
import { Token } from "../tokens";
|
||||
import { removeGroupsItems } from "../helpers/group";
|
||||
|
||||
type TokenDataContext = {
|
||||
tokens: Token[];
|
||||
ownedTokens: Token[];
|
||||
addToken: (token: Token) => Promise<void>;
|
||||
removeToken: (id: string) => Promise<void>;
|
||||
tokenGroups: any[];
|
||||
removeTokens: (ids: string[]) => Promise<void>;
|
||||
updateToken: (id: string, update: Partial<Token>) => Promise<void>;
|
||||
updateTokens: (ids: string[], update: Partial<Token>) => Promise<void>;
|
||||
putToken: (token: Token) => Promise<void>;
|
||||
getToken: (tokenId: string) => Token | undefined
|
||||
tokensById: { [key: string]: Token; };
|
||||
getToken: (tokenId: string) => Promise<Token | undefined>;
|
||||
tokensById: Record<string, Token>;
|
||||
tokensLoading: boolean;
|
||||
getTokenFromDB: (tokenId: string) => Promise<Token>;
|
||||
loadTokens: (tokenIds: string[]) => Promise<void>;
|
||||
}
|
||||
updateTokenGroups: (groups: any[]) => void;
|
||||
updateTokensHidden: (ids: string[], hideInSidebar: boolean) => void;
|
||||
};
|
||||
|
||||
const TokenDataContext = React.createContext<TokenDataContext | undefined>(undefined);
|
||||
const TokenDataContext =
|
||||
React.createContext<TokenDataContext | undefined>(undefined);
|
||||
|
||||
const cachedTokenMax = 100;
|
||||
export function TokenDataProvider({ children }: { children: React.ReactNode }) {
|
||||
const { database } = useDatabase();
|
||||
|
||||
export function TokenDataProvider({ children }: { children: any }) {
|
||||
const { database, databaseStatus, worker } = useDatabase();
|
||||
const { userId } = useAuth();
|
||||
const tokensQuery = useLiveQuery<Token[]>(
|
||||
() => database?.table("tokens").toArray() || [],
|
||||
[database]
|
||||
);
|
||||
|
||||
/**
|
||||
* Contains all tokens without any file data,
|
||||
* to ensure file data is present call loadTokens
|
||||
*/
|
||||
const [tokens, setTokens] = useState<Token[]>([]);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
const tokens = useMemo(() => tokensQuery || [], [tokensQuery]);
|
||||
const tokensLoading = useMemo(() => !tokensQuery, [tokensQuery]);
|
||||
|
||||
const tokenGroupQuery = useLiveQuery(
|
||||
() => database?.table("groups").get("tokens"),
|
||||
[database]
|
||||
);
|
||||
|
||||
const [tokenGroups, setTokenGroups] = useState([]);
|
||||
useEffect(() => {
|
||||
if (!userId || !database || databaseStatus === "loading") {
|
||||
return;
|
||||
async function updateTokenGroups() {
|
||||
const group = await database?.table("groups").get("tokens");
|
||||
setTokenGroups(group.items);
|
||||
}
|
||||
function getDefaultTokens() {
|
||||
const defaultTokensWithIds: Required<DefaultToken[]> = [];
|
||||
for (let defaultToken of defaultTokens) {
|
||||
defaultTokensWithIds.push({
|
||||
...defaultToken,
|
||||
id: `__default-${defaultToken.name}`,
|
||||
owner: userId,
|
||||
});
|
||||
}
|
||||
return defaultTokensWithIds;
|
||||
if (database && tokenGroupQuery) {
|
||||
updateTokenGroups();
|
||||
}
|
||||
}, [tokenGroupQuery, database]);
|
||||
|
||||
// Loads tokens without the file data to save memory
|
||||
async function loadTokens() {
|
||||
let storedTokens: any = [];
|
||||
// Try to load tokens with worker, fallback to database if failed
|
||||
const packedTokens: ArrayLike<number> | BufferSource = await worker.loadData("tokens");
|
||||
if (packedTokens) {
|
||||
storedTokens = decode(packedTokens);
|
||||
} else {
|
||||
console.warn("Unable to load tokens with worker, loading may be slow");
|
||||
await database?.table("tokens").each((token: FileToken) => {
|
||||
const { file, ...rest } = token;
|
||||
storedTokens.push(rest);
|
||||
});
|
||||
}
|
||||
const sortedTokens = storedTokens.sort((a: any, b: any) => b.created - a.created);
|
||||
const defaultTokensWithIds = getDefaultTokens();
|
||||
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
|
||||
setTokens(allTokens);
|
||||
setTokensLoading(false);
|
||||
}
|
||||
|
||||
loadTokens();
|
||||
}, [userId, database, databaseStatus, worker]);
|
||||
|
||||
const tokensRef = useRef(tokens);
|
||||
useEffect(() => {
|
||||
tokensRef.current = tokens;
|
||||
}, [tokens]);
|
||||
|
||||
const getToken = useCallback((tokenId) => {
|
||||
return tokensRef.current.find((token) => token.id === tokenId);
|
||||
}, []);
|
||||
|
||||
const getTokenFromDB = useCallback(
|
||||
const getToken = useCallback(
|
||||
async (tokenId) => {
|
||||
let token = await database?.table("tokens").get(tokenId);
|
||||
return token;
|
||||
@@ -100,165 +63,89 @@ export function TokenDataProvider({ children }: { children: any }) {
|
||||
[database]
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep up to cachedTokenMax amount of tokens that you don't own
|
||||
* Sorted by when they we're last used
|
||||
*/
|
||||
const updateCache = useCallback(async () => {
|
||||
const cachedTokens: Token[] | undefined = await database?.table("tokens").where("owner").notEqual(userId).sortBy("lastUsed");
|
||||
// TODO: handle undefined cachedTokens
|
||||
if (!cachedTokens) {
|
||||
return;
|
||||
}
|
||||
if (cachedTokens?.length > cachedTokenMax) {
|
||||
const cacheDeleteCount = cachedTokens.length - cachedTokenMax
|
||||
const idsToDelete = cachedTokens
|
||||
.slice(0, cacheDeleteCount)
|
||||
.map((token) => token.id);
|
||||
database?.table("tokens").where("id").anyOf(idsToDelete).delete();
|
||||
}
|
||||
}, [database, userId]);
|
||||
|
||||
// Add token and add it to the token group
|
||||
const addToken = useCallback(
|
||||
async (token) => {
|
||||
await database?.table("tokens").add(token);
|
||||
if (token.owner !== userId) {
|
||||
await updateCache();
|
||||
if (database) {
|
||||
await database.table("tokens").add(token);
|
||||
const group = await database.table("groups").get("tokens");
|
||||
await database.table("groups").update("tokens", {
|
||||
items: [{ id: token.id, type: "item" }, ...group.items],
|
||||
});
|
||||
}
|
||||
},
|
||||
[database, updateCache, userId]
|
||||
);
|
||||
|
||||
const removeToken = useCallback(
|
||||
async (id: string) => {
|
||||
await database?.table("tokens").delete(id);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const removeTokens = useCallback(
|
||||
async (ids: string[]) => {
|
||||
await database?.table("tokens").bulkDelete(ids);
|
||||
async (ids) => {
|
||||
if (database) {
|
||||
const tokens = await database.table("tokens").bulkGet(ids);
|
||||
let assetIds = [];
|
||||
for (let token of tokens) {
|
||||
if (token.type === "file") {
|
||||
assetIds.push(token.file);
|
||||
assetIds.push(token.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
const group = await database.table("groups").get("tokens");
|
||||
let items = removeGroupsItems(group.items, ids);
|
||||
await database.table("groups").update("tokens", { items });
|
||||
|
||||
await database.table("tokens").bulkDelete(ids);
|
||||
await database.table("assets").bulkDelete(assetIds);
|
||||
}
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const updateToken = useCallback(
|
||||
async (id: string, update: any) => {
|
||||
const change = { lastModified: Date.now(), ...update };
|
||||
await database?.table("tokens").update(id, change);
|
||||
async (id, update) => {
|
||||
await database?.table("tokens").update(id, update);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const updateTokens = useCallback(
|
||||
async (ids, update) => {
|
||||
const change = { lastModified: Date.now(), ...update };
|
||||
const updateTokensHidden = useCallback(
|
||||
async (ids: string[], hideInSidebar: boolean) => {
|
||||
await Promise.all(
|
||||
ids.map((id: string) => database?.table("tokens").update(id, change))
|
||||
ids.map((id) => database?.table("tokens").update(id, { hideInSidebar }))
|
||||
);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const putToken = useCallback(
|
||||
async (token) => {
|
||||
await database?.table("tokens").put(token);
|
||||
if (token.owner !== userId) {
|
||||
await updateCache();
|
||||
}
|
||||
},
|
||||
[database, updateCache, userId]
|
||||
);
|
||||
|
||||
const loadTokens = useCallback(
|
||||
async (tokenIds: string[]) => {
|
||||
const loadedTokens: FileToken[] | undefined = await database?.table("tokens").bulkGet(tokenIds);
|
||||
const loadedTokensById = loadedTokens?.reduce((obj: { [key: string]: FileToken }, token: FileToken) => {
|
||||
obj[token.id] = token;
|
||||
return obj;
|
||||
}, {});
|
||||
if (!loadedTokensById) {
|
||||
// TODO: whatever
|
||||
return;
|
||||
}
|
||||
setTokens((prevTokens: Token[]) => {
|
||||
return prevTokens.map((prevToken) => {
|
||||
if (prevToken.id in loadedTokensById) {
|
||||
return loadedTokensById[prevToken.id];
|
||||
} else {
|
||||
return prevToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
const updateTokenGroups = useCallback(
|
||||
async (groups) => {
|
||||
// Update group state immediately to avoid animation delay
|
||||
setTokenGroups(groups);
|
||||
await database?.table("groups").update("tokens", { items: groups });
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
// Create DB observable to sync creating and deleting
|
||||
const [tokensById, setTokensById] = useState({});
|
||||
useEffect(() => {
|
||||
if (!database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleTokenChanges(changes: any) {
|
||||
for (let change of changes) {
|
||||
if (change.table === "tokens") {
|
||||
if (change.type === 1) {
|
||||
// Created
|
||||
const token = change.obj;
|
||||
setTokens((prevTokens) => [token, ...prevTokens]);
|
||||
} else if (change.type === 2) {
|
||||
// Updated
|
||||
const token = change.obj;
|
||||
setTokens((prevTokens) => {
|
||||
const newTokens = [...prevTokens];
|
||||
const i = newTokens.findIndex((t) => t.id === token.id);
|
||||
if (i > -1) {
|
||||
newTokens[i] = token;
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
} else if (change.type === 3) {
|
||||
// Deleted
|
||||
const id = change.key;
|
||||
setTokens((prevTokens) => {
|
||||
const filtered = prevTokens.filter((token) => token.id !== id);
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database.on("changes", handleTokenChanges);
|
||||
|
||||
return () => {
|
||||
database.on("changes").unsubscribe(handleTokenChanges);
|
||||
};
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
const ownedTokens = tokens.filter((token) => token.owner === userId);
|
||||
|
||||
const tokensById: { [key: string]: Token; } = tokens.reduce((obj: { [key: string]: Token }, token) => {
|
||||
obj[token.id] = token;
|
||||
return obj;
|
||||
}, {});
|
||||
setTokensById(
|
||||
tokens.reduce((obj: Record<string, Token>, token: Token) => {
|
||||
obj[token.id] = token;
|
||||
return obj;
|
||||
}, {})
|
||||
);
|
||||
}, [tokens]);
|
||||
|
||||
const value: TokenDataContext = {
|
||||
tokens,
|
||||
ownedTokens,
|
||||
addToken,
|
||||
removeToken,
|
||||
tokenGroups,
|
||||
removeTokens,
|
||||
updateToken,
|
||||
updateTokens,
|
||||
putToken,
|
||||
getToken,
|
||||
tokensById,
|
||||
tokensLoading,
|
||||
getTokenFromDB,
|
||||
loadTokens,
|
||||
getToken,
|
||||
updateTokenGroups,
|
||||
updateTokensHidden,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
36
src/contexts/UserIdContext.js
Normal file
36
src/contexts/UserIdContext.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
/**
|
||||
* @type {React.Context<string|undefined>}
|
||||
*/
|
||||
const UserIdContext = React.createContext();
|
||||
|
||||
export function UserIdProvider({ children }) {
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
|
||||
const [userId, setUserId] = useState();
|
||||
useEffect(() => {
|
||||
if (!database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
async function loadUserId() {
|
||||
const storedUserId = await database.table("user").get("userId");
|
||||
if (storedUserId) {
|
||||
setUserId(storedUserId.value);
|
||||
}
|
||||
}
|
||||
|
||||
loadUserId();
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
return (
|
||||
<UserIdContext.Provider value={userId}>{children}</UserIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserId() {
|
||||
return useContext(UserIdContext);
|
||||
}
|
||||
|
||||
export default UserIdContext;
|
||||
Reference in New Issue
Block a user