{cardSets.map((set) =>
@@ -49,7 +39,6 @@ export function DeckCreateCardSets() {
diff --git a/src/pages/deck-edit/deck-edit.tsx b/src/pages/deck-edit/deck-edit.tsx
index 39d44057..20e30e5b 100644
--- a/src/pages/deck-edit/deck-edit.tsx
+++ b/src/pages/deck-edit/deck-edit.tsx
@@ -1,67 +1,105 @@
-import { useCallback, useEffect } from "react";
-import { Redirect } from "wouter";
+import { useEffect, useMemo, useState } from "react";
+import { Redirect, useParams } from "wouter";
import { ListLayout } from "@//layouts/list-layout";
import { CardList } from "@/components/card-list/card-list";
-import { useCardModalContext } from "@/components/card-modal/card-modal-context";
+import { CardModalProvider } from "@/components/card-modal/card-modal-context";
import { DecklistValidation } from "@/components/decklist/decklist-validation";
import { Filters } from "@/components/filters/filters";
import { useStore } from "@/store";
-import { selectActiveDeck, selectDeckValid } from "@/store/selectors/decks";
+import type { DisplayDeck } from "@/store/lib/deck-grouping";
+import {
+ selectActiveDeckById,
+ selectDeckValidById,
+} from "@/store/selectors/deck-view";
+import { type Tab, mapTabToSlot } from "@/store/slices/deck-edits.types";
+import { DeckIdProvider } from "@/utils/use-deck-id";
import { useDocumentTitle } from "@/utils/use-document-title";
import { Editor } from "./editor/editor";
import { ShowUnusableCardsToggle } from "./show-unusable-cards-toggle";
function DeckEdit() {
- const cardModalContext = useCardModalContext();
- const deckId = useStore((state) => state.deckView?.id);
- const activeListId = useStore((state) => state.activeList);
- const deck = useStore(selectActiveDeck);
+ const { id } = useParams<{ id: string }>();
+ const activeListId = useStore((state) => state.activeList);
const resetFilters = useStore((state) => state.resetFilters);
const setActiveList = useStore((state) => state.setActiveList);
-
- const validation = useStore(selectDeckValid);
+ const deck = useStore((state) => selectActiveDeckById(state, id, true));
useEffect(() => {
setActiveList("editor_player");
return () => {
resetFilters();
};
- }, [resetFilters, setActiveList]);
+ }, [setActiveList, resetFilters]);
- useDocumentTitle(
- deck ? `Edit: ${deck.investigatorFront.card.real_name} - ${deck.name}` : "",
+ if (id && !deck) {
+ return
;
+ }
+
+ if (!deck || !activeListId?.startsWith("editor")) return null;
+
+ return (
+
+
+
+
+
);
+}
+
+function DeckEditInner({ deck }: { deck: DisplayDeck }) {
+ const [showUnusableCards, setShowUnusableCards] = useState(false);
+ const [currentTab, setCurrentTab] = useState
("slots");
+
+ const updateCardQuantity = useStore((state) => state.updateCardQuantity);
- const onOpenModal = useCallback(
- (code: string) => {
- cardModalContext.setOpen({ code, deckId, canEdit: true });
- },
- [cardModalContext, deckId],
+ const validation = useStore((state) =>
+ selectDeckValidById(state, deck.id, true),
);
- if (deckId && !deck) {
- return ;
- }
+ useDocumentTitle(
+ deck ? `Edit: ${deck.investigatorFront.card.real_name} - ${deck.name}` : "",
+ );
- if (!deck || !activeListId?.startsWith("editor")) return null;
+ const onChangeCardQuantity = useMemo(() => {
+ return (code: string, quantity: number) => {
+ updateCardQuantity(deck.id, code, quantity, mapTabToSlot(currentTab));
+ };
+ }, [updateCardQuantity, currentTab, deck.id]);
return (
-
+
}
sidebar={
-
+
}
sidebarWidthMax="42rem"
>
- {(props) => }
+ {(props) => (
+
+ )}
);
}
diff --git a/src/pages/deck-edit/editor/editor-actions.tsx b/src/pages/deck-edit/editor/editor-actions.tsx
index c9df4f85..54dab6cf 100644
--- a/src/pages/deck-edit/editor/editor-actions.tsx
+++ b/src/pages/deck-edit/editor/editor-actions.tsx
@@ -1,6 +1,6 @@
import { Save } from "lucide-react";
import { useCallback } from "react";
-import { Link, useLocation } from "wouter";
+import { useLocation } from "wouter";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/toast";
@@ -21,10 +21,11 @@ export function EditorActions({ deck }: Props) {
deck.cards.investigator.card.faction_code,
);
+ const discardEdits = useStore((state) => state.discardEdits);
const saveDeck = useStore((state) => state.saveDeck);
const handleSave = useCallback(() => {
- const id = saveDeck();
+ const id = saveDeck(deck.id);
navigate(`/deck/view/${id}`, {
state: { confirm: false },
@@ -34,7 +35,17 @@ export function EditorActions({ deck }: Props) {
children: "Deck saved successfully.",
variant: "success",
});
- }, [saveDeck, navigate, showToast]);
+ }, [saveDeck, navigate, showToast, deck.id]);
+
+ const handleDiscard = useCallback(() => {
+ const confirmed = window.confirm(
+ "Are you sure you want to discard your changes?",
+ );
+ if (confirmed) {
+ discardEdits(deck.id);
+ navigate(`/deck/view/${deck.id}`);
+ }
+ }, [discardEdits, navigate, deck.id]);
return (
@@ -42,9 +53,9 @@ export function EditorActions({ deck }: Props) {
Save
-
-
-
+
);
}
diff --git a/src/pages/deck-edit/editor/editor.tsx b/src/pages/deck-edit/editor/editor.tsx
index ec712cda..fcfe8fd7 100644
--- a/src/pages/deck-edit/editor/editor.tsx
+++ b/src/pages/deck-edit/editor/editor.tsx
@@ -3,10 +3,9 @@ import { DecklistGroups } from "@/components/decklist/decklist-groups";
import { DecklistSection } from "@/components/decklist/decklist-section";
import { Scroller } from "@/components/ui/scroller";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { useStore } from "@/store";
import type { DisplayDeck } from "@/store/lib/deck-grouping";
import type { DeckValidationResult } from "@/store/lib/deck-validation";
-import { selectCurrentTab } from "@/store/selectors/decks";
+import type { Tab } from "@/store/slices/deck-edits.types";
import css from "./editor.module.css";
@@ -16,15 +15,13 @@ import { MetaEditor } from "./meta-editor";
type Props = {
className?: string;
+ currentTab: Tab;
+ onTabChange: (tab: Tab) => void;
deck: DisplayDeck;
- onOpenModal: (code: string) => void;
validation?: DeckValidationResult;
};
-export function Editor({ deck, onOpenModal, validation }: Props) {
- const currentTab = useStore(selectCurrentTab);
- const updateActiveTab = useStore((state) => state.updateActiveTab);
-
+export function Editor({ currentTab, deck, onTabChange, validation }: Props) {
return (
@@ -35,7 +32,7 @@ export function Editor({ deck, onOpenModal, validation }: Props) {
className={css["editor-tabs"]}
length={deck.hasExtraDeck ? 4 : 3}
onValueChange={(value: string) => {
- updateActiveTab(value);
+ onTabChange(value as Tab);
}}
value={currentTab}
>
@@ -52,24 +49,20 @@ export function Editor({ deck, onOpenModal, validation }: Props) {
@@ -80,7 +73,6 @@ export function Editor({ deck, onOpenModal, validation }: Props) {
group={deck.groups.bonded.data}
layout="two_column"
mapping="bonded"
- onOpenModal={onOpenModal}
ownershipCounts={deck.ownershipCounts}
quantities={deck.bondedSlots}
/>
@@ -92,11 +84,9 @@ export function Editor({ deck, onOpenModal, validation }: Props) {
{deck.groups.side?.data ? (
@@ -111,11 +101,9 @@ export function Editor({ deck, onOpenModal, validation }: Props) {
{deck.groups.extra?.data ? (
diff --git a/src/pages/deck-edit/editor/investigator-listcard.tsx b/src/pages/deck-edit/editor/investigator-listcard.tsx
index fd6f47de..7a687738 100644
--- a/src/pages/deck-edit/editor/investigator-listcard.tsx
+++ b/src/pages/deck-edit/editor/investigator-listcard.tsx
@@ -34,6 +34,7 @@ function InvestigatorListcardInner({ deck }: Props) {
) => {
if (evt.target instanceof HTMLSelectElement) {
const value = Number.parseInt(evt.target.value, 10);
- updateTabooId(Number.isNaN(value) ? null : value);
+ updateTabooId(deck.id, Number.isNaN(value) ? null : value);
}
},
- [updateTabooId],
+ [updateTabooId, deck.id],
);
const onNameChange = useCallback(
@@ -79,19 +79,19 @@ export function MetaEditor({ deck }: Props) {
const onDescriptionChange = useCallback(
(evt: React.ChangeEvent) => {
if (evt.target instanceof HTMLTextAreaElement) {
- updateDescription(evt.target.value);
+ updateDescription(deck.id, evt.target.value);
}
},
- [updateDescription],
+ [updateDescription, deck.id],
);
const onTagsChange = useCallback(
(evt: React.ChangeEvent) => {
if (evt.target instanceof HTMLInputElement) {
- updateTags(evt.target.value);
+ updateTags(deck.id, evt.target.value);
}
},
- [updateTags],
+ [updateTags, deck.id],
);
const onFieldChange = useCallback(
@@ -101,6 +101,7 @@ export function MetaEditor({ deck }: Props) {
if (evt.target.dataset.field && evt.target.dataset.type) {
updateMetaProperty(
+ deck.id,
evt.target.dataset.field,
value || null,
evt.target.dataset.type as DeckOptionSelectType,
@@ -108,7 +109,7 @@ export function MetaEditor({ deck }: Props) {
}
}
},
- [updateMetaProperty],
+ [updateMetaProperty, deck.id],
);
const onInvestigatorSideChange = useCallback(
@@ -116,11 +117,11 @@ export function MetaEditor({ deck }: Props) {
if (evt.target instanceof HTMLSelectElement) {
const value = evt.target.value;
if (evt.target.dataset.side) {
- updateInvestigatorSide(evt.target.dataset.side, value);
+ updateInvestigatorSide(deck.id, evt.target.dataset.side, value);
}
}
},
- [updateInvestigatorSide],
+ [updateInvestigatorSide, deck.id],
);
return (
diff --git a/src/pages/deck-edit/show-unusable-cards-toggle.tsx b/src/pages/deck-edit/show-unusable-cards-toggle.tsx
index fcad64f0..3965b706 100644
--- a/src/pages/deck-edit/show-unusable-cards-toggle.tsx
+++ b/src/pages/deck-edit/show-unusable-cards-toggle.tsx
@@ -1,29 +1,21 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Field } from "@/components/ui/field";
-import { useStore } from "@/store";
import css from "./deck-edit.module.css";
-export function ShowUnusableCardsToggle() {
- const showUnusableCards = useStore(
- (state) => state.deckView?.showUnusableCards ?? false,
- );
-
- const updateShowUnusableCards = useStore(
- (state) => state.updateShowUnusableCards,
- );
-
- const handleValueChange = (val: boolean) => {
- updateShowUnusableCards(val);
- };
+type Props = {
+ checked: boolean;
+ onValueChange: (checked: boolean) => void;
+};
+export function ShowUnusableCardsToggle({ checked, onValueChange }: Props) {
return (
);
diff --git a/src/pages/deck-view/deck-view.tsx b/src/pages/deck-view/deck-view.tsx
index c1988d74..2d074a8d 100644
--- a/src/pages/deck-view/deck-view.tsx
+++ b/src/pages/deck-view/deck-view.tsx
@@ -1,17 +1,18 @@
-import { useCallback } from "react";
import { Redirect, useParams } from "wouter";
-import { useCardModalContext } from "@/components/card-modal/card-modal-context";
+import { CardModalProvider } from "@/components/card-modal/card-modal-context";
import { DeckTags } from "@/components/deck-tags";
import { Decklist } from "@/components/decklist/decklist";
import { DecklistValidation } from "@/components/decklist/decklist-validation";
import { Dialog } from "@/components/ui/dialog";
import { AppLayout } from "@/layouts/app-layout";
import { useStore } from "@/store";
+import type { DisplayDeck } from "@/store/lib/deck-grouping";
import {
selectActiveDeckById,
selectDeckValidById,
} from "@/store/selectors/deck-view";
+import { DeckIdProvider } from "@/utils/use-deck-id";
import css from "./deck-view.module.css";
@@ -19,20 +20,22 @@ import { DeckNotes } from "./deck-notes";
import { Sidebar } from "./sidebar/sidebar";
function DeckView() {
- const cardModalContext = useCardModalContext();
const { id } = useParams<{ id: string }>();
-
const deck = useStore((state) => selectActiveDeckById(state, id));
- const validation = useStore((state) => selectDeckValidById(state, id));
- const onOpenModal = useCallback(
- (code: string) => {
- cardModalContext.setOpen({ code, deckId: id });
- },
- [cardModalContext, id],
+ if (!deck) return ;
+
+ return (
+
+
+
+
+
);
+}
- if (!deck) return ;
+function DeckViewInner({ deck }: { deck: DisplayDeck }) {
+ const validation = useStore((state) => selectDeckValidById(state, deck.id));
return (
-
+
{deck.description_md && (
diff --git a/src/store/index.ts b/src/store/index.ts
index f958cbcb..c33afd90 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -4,7 +4,7 @@ import { devtools, persist } from "zustand/middleware";
import type { StoreState } from "./slices";
import { createDataSlice } from "./slices/data";
import { createDeckCreateSlice } from "./slices/deck-create";
-import { createDeckViewSlice } from "./slices/deck-view";
+import { createDeckEditsSlice } from "./slices/deck-edits";
import { createListsSlice } from "./slices/lists";
import { createLookupTablesSlice } from "./slices/lookup-tables";
import { createMetadataSlice } from "./slices/metadata";
@@ -22,7 +22,7 @@ export const stateCreator = (...args: [any, any, any]) => ({
...createSettingsSlice(...args),
...createUISlice(...args),
...createSharedSlice(...args),
- ...createDeckViewSlice(...args),
+ ...createDeckEditsSlice(...args),
...createDeckCreateSlice(...args),
});
diff --git a/src/store/lib/deck-edits.ts b/src/store/lib/deck-edits.ts
index 95bd712f..5ddbbb19 100644
--- a/src/store/lib/deck-edits.ts
+++ b/src/store/lib/deck-edits.ts
@@ -1,7 +1,7 @@
import { isEmpty } from "@/utils/is-empty";
import type { Deck, Slots } from "../slices/data.types";
-import type { DeckViewState, EditState, Slot } from "../slices/deck-view.types";
+import type { EditState, Slot } from "../slices/deck-edits.types";
import type { Metadata } from "../slices/metadata.types";
import {
decodeCustomizations,
@@ -21,43 +21,42 @@ import type { DeckMeta } from "./types";
*/
export function applyDeckEdits(
originalDeck: Deck,
- deckView: DeckViewState,
+ edits: EditState | undefined,
metadata: Metadata,
alwaysDeleteEmpty = false,
) {
+ if (!edits) return originalDeck;
+
const deck = structuredClone(originalDeck);
- if (deckView.edits.name != undefined) {
- deck.name = deckView.edits.name;
+ if (edits.name != undefined) {
+ deck.name = edits.name;
}
- if (deckView.edits.description_md != undefined) {
- deck.description_md = deckView.edits.description_md;
+ if (edits.description_md != undefined) {
+ deck.description_md = edits.description_md;
}
- if (deckView.edits.tags != undefined) {
- deck.tags = deckView.edits.tags;
+ if (edits.tags != undefined) {
+ deck.tags = edits.tags;
}
// adjust taboo id based on deck edits.
- if (deckView.edits.tabooId !== undefined) {
- deck.taboo_id = deckView.edits.tabooId;
+ if (edits.tabooId !== undefined) {
+ deck.taboo_id = edits.tabooId;
}
// adjust meta based on deck edits.
const deckMeta = decodeDeckMeta(deck);
// adjust customizations based on deck edits.
- Object.assign(
- deckMeta,
- mergeCustomizationEdits(deckView, deckMeta, metadata),
- );
+ Object.assign(deckMeta, mergeCustomizationEdits(edits, deckMeta, metadata));
const extraSlots = decodeExtraSlots(deckMeta);
// adjust quantities based on deck edits.
- for (const [key, edits] of Object.entries(deckView.edits.quantities)) {
- for (const [code, value] of Object.entries(edits)) {
+ for (const [key, quantityEdits] of Object.entries(edits.quantities)) {
+ for (const [code, value] of Object.entries(quantityEdits)) {
const slotKey = key as Slot;
if (slotKey === "extraSlots") {
@@ -100,18 +99,18 @@ export function applyDeckEdits(
deck.meta = JSON.stringify({
...deckMeta,
- ...deckView.edits.meta,
+ ...edits.meta,
extra_deck: encodeExtraSlots(extraSlots),
alternate_back: applyInvestigatorSide(
deck,
deckMeta,
- deckView,
+ edits,
"investigatorBack",
),
alternate_front: applyInvestigatorSide(
deck,
deckMeta,
- deckView,
+ edits,
"investigatorFront",
),
});
@@ -122,10 +121,10 @@ export function applyDeckEdits(
function applyInvestigatorSide(
deck: Deck,
deckMeta: DeckMeta,
- deckView: EditState,
+ edits: EditState,
key: "investigatorFront" | "investigatorBack",
) {
- const current = deckView.edits[key];
+ const current = edits[key];
if (!current) {
const deckMetaKey =
@@ -140,17 +139,17 @@ function applyInvestigatorSide(
* Merges stored customizations in a deck with edits, returning a deck.meta JSON block.
*/
export function mergeCustomizationEdits(
- state: EditState,
+ edits: EditState,
deckMeta: DeckMeta,
metadata: Metadata,
) {
- if (isEmpty(state.edits.customizations)) {
+ if (isEmpty(edits.customizations)) {
return {};
}
const customizations = decodeCustomizations(deckMeta, metadata) ?? {};
- for (const [code, changes] of Object.entries(state.edits.customizations)) {
+ for (const [code, changes] of Object.entries(edits.customizations)) {
customizations[code] ??= {};
for (const [id, change] of Object.entries(changes)) {
diff --git a/src/store/lib/filtering.ts b/src/store/lib/filtering.ts
index 957a1bdf..dac7e821 100644
--- a/src/store/lib/filtering.ts
+++ b/src/store/lib/filtering.ts
@@ -744,6 +744,7 @@ export function filterInvestigatorAccess(
if (mode === "extraSlots") return extraDeckFilter;
const filters = [];
+
if (deckFilter) filters.push(deckFilter);
if (extraDeckFilter) filters.push(extraDeckFilter);
return or(filters);
@@ -818,15 +819,19 @@ function makePlayerCardsFilter(
export function filterInvestigatorWeaknessAccess(
card: Card,
lookupTables: LookupTables,
+ config?: Pick,
) {
// normalize parallel investigators to root for lookups.
const code = card.alternate_of_code ?? card.code;
- const ors: Filter[] = [
- filterRequired(code, lookupTables.relations),
- filterSubtypes(["basicweakness"]),
- (card: Card) => card.xp == null && !card.restrictions,
- ];
+ const ors: Filter[] =
+ config?.targetDeck !== "extraSlots"
+ ? [
+ filterRequired(code, lookupTables.relations),
+ filterSubtypes(["basicweakness"]),
+ (card: Card) => card.xp == null && !card.restrictions,
+ ]
+ : [(c: Card) => !!card.side_deck_requirements?.card?.[c.code]];
return and([
filterSubtypes(["basicweakness", "weakness"]),
diff --git a/src/store/selectors/card-list.ts b/src/store/selectors/card-list.ts
index 55ba4dbf..7f30a4ab 100644
--- a/src/store/selectors/card-list.ts
+++ b/src/store/selectors/card-list.ts
@@ -31,6 +31,7 @@ import { applySearch } from "../lib/searching";
import { makeSortFunction } from "../lib/sorting";
import type { Card } from "../services/queries.types";
import type { StoreState } from "../slices";
+import type { Id } from "../slices/data.types";
import type {
AssetFilter,
CostFilter,
@@ -44,7 +45,7 @@ import type {
import type { LookupTables } from "../slices/lookup-tables.types";
import type { Metadata } from "../slices/metadata.types";
import type { SettingsState } from "../slices/settings.types";
-import { selectResolvedDeck } from "./decks";
+import { selectResolvedDeckById } from "./deck-view";
import { selectActiveList, selectCanonicalTabooSetId } from "./lists";
export type CardGroup = {
@@ -65,6 +66,7 @@ function makeUserFilter(
list: List,
settings: SettingsState,
deckInvestigatorFilter?: Filter,
+ targetDeck?: "slots" | "extraSlots" | "both",
) {
const filters: Filter[] = [];
@@ -118,10 +120,12 @@ function makeUserFilter(
const accessFilter = filterInvestigatorAccess(
metadata.cards[value],
lookupTables,
+ { targetDeck },
);
const weaknessFilter = filterInvestigatorWeaknessAccess(
metadata.cards[value],
lookupTables,
+ { targetDeck },
);
if (accessFilter) filter.push(accessFilter);
@@ -213,10 +217,14 @@ function makeUserFilter(
// only when the investigator back changes or certain slots are changed.
export const selectDeckInvestigatorFilter = createSelector(
(state: StoreState) => state.lookupTables,
- selectResolvedDeck,
- (state: StoreState) =>
- state.deckView?.activeTab === "extraSlots" ? "extraSlots" : "slots",
- (state: StoreState) => !!state.deckView?.showUnusableCards,
+ selectResolvedDeckById,
+ (
+ state: StoreState,
+ id?: Id,
+ applyEdits?: boolean,
+ targetDeck?: "slots" | "extraSlots" | "both",
+ ) => targetDeck,
+ () => false, // FIXME
(lookupTables, resolvedDeck, targetDeck, showUnusableCards) => {
if (!resolvedDeck) return undefined;
@@ -250,6 +258,9 @@ export const selectDeckInvestigatorFilter = createSelector(
const weaknessFilter = filterInvestigatorWeaknessAccess(
investigator,
lookupTables,
+ {
+ targetDeck,
+ },
);
if (investigatorFilter) ors.push(investigatorFilter);
@@ -264,9 +275,15 @@ export const selectListCards = createSelector(
(state: StoreState) => state.lookupTables,
(state: StoreState) => state.settings,
selectActiveList,
- selectResolvedDeck,
+ selectResolvedDeckById,
selectCanonicalTabooSetId,
selectDeckInvestigatorFilter,
+ (
+ state: StoreState,
+ id?: Id,
+ applyEdits?: boolean,
+ targetDeck?: "slots" | "extraSlots" | "both",
+ ) => targetDeck,
(
metadata,
lookupTables,
diff --git a/src/store/selectors/card-view.ts b/src/store/selectors/card-view.ts
index 2e05933a..3ec19c8c 100644
--- a/src/store/selectors/card-view.ts
+++ b/src/store/selectors/card-view.ts
@@ -1,20 +1,23 @@
import { resolveCardWithRelations } from "../lib/resolve-card";
import type { CardWithRelations, ResolvedCard } from "../lib/types";
import type { StoreState } from "../slices";
-import { selectActiveDeck } from "./decks";
+import type { Id } from "../slices/data.types";
+import { selectResolvedDeckById } from "./deck-view";
import { selectCanonicalTabooSetId } from "./lists";
export function selectCardWithRelations(
state: StoreState,
code: string | undefined,
withRelations: T,
+ deckId: Id | undefined,
+ applyEdits?: boolean,
): T extends true ? undefined | CardWithRelations : undefined | ResolvedCard {
return resolveCardWithRelations(
state.metadata,
state.lookupTables,
code,
- selectCanonicalTabooSetId(state),
- selectActiveDeck(state)?.customizations,
+ selectCanonicalTabooSetId(state, deckId, applyEdits),
+ selectResolvedDeckById(state, deckId, applyEdits)?.customizations,
withRelations,
);
}
diff --git a/src/store/selectors/deck-view.ts b/src/store/selectors/deck-view.ts
index b9d0fb22..11572887 100644
--- a/src/store/selectors/deck-view.ts
+++ b/src/store/selectors/deck-view.ts
@@ -1,22 +1,35 @@
import { createSelector } from "reselect";
+import { SPECIAL_CARD_CODES } from "@/utils/constants";
+
+import { applyDeckEdits } from "../lib/deck-edits";
import type { DisplayDeck } from "../lib/deck-grouping";
import { groupDeckCardsByType } from "../lib/deck-grouping";
+import type { ForbiddenCardError } from "../lib/deck-validation";
import { validateDeck } from "../lib/deck-validation";
import { resolveDeck } from "../lib/resolve-deck";
+import type { Card } from "../services/queries.types";
import type { StoreState } from "../slices";
+import type { Id } from "../slices/data.types";
export const selectResolvedDeckById = createSelector(
(state: StoreState) => state.metadata,
(state: StoreState) => state.lookupTables,
- (state: StoreState) => state.data.decks,
- (_: StoreState, id: string) => id,
- (metadata, lookupTables, decks, id) => {
- const deck = decks[id];
+ (state: StoreState, deckId?: Id) =>
+ deckId ? state.data.decks[deckId] : undefined,
+ (state: StoreState, deckId?: Id, applyEdits?: boolean) =>
+ deckId && applyEdits ? state.deckEdits?.[deckId] : undefined,
+ (metadata, lookupTables, deck, edits) => {
if (!deck) return undefined;
console.time("[perf] select_resolved_deck");
- const resolvedDeck = resolveDeck(metadata, lookupTables, deck, true);
+
+ const resolvedDeck = resolveDeck(
+ metadata,
+ lookupTables,
+ edits ? applyDeckEdits(deck, edits, metadata) : deck,
+ true,
+ );
console.timeEnd("[perf] select_resolved_deck");
return resolvedDeck;
@@ -24,7 +37,8 @@ export const selectResolvedDeckById = createSelector(
);
export const selectActiveDeckById = createSelector(
- (state: StoreState, id: string) => selectResolvedDeckById(state, id),
+ (state: StoreState, id: Id, applyEdits?: boolean) =>
+ selectResolvedDeckById(state, id, applyEdits),
(state: StoreState) => state.metadata,
(state: StoreState) => state.lookupTables,
(state: StoreState) => state.settings.collection,
@@ -57,7 +71,8 @@ export const selectActiveDeckById = createSelector(
);
export const selectDeckValidById = createSelector(
- (state: StoreState, id: string) => selectResolvedDeckById(state, id),
+ (state: StoreState, id: Id, applyDeckEdits?: boolean) =>
+ selectResolvedDeckById(state, id, applyDeckEdits),
(state: StoreState) => state.lookupTables,
(state: StoreState) => state.metadata,
(deck, lookupTables, metadata) => {
@@ -66,3 +81,36 @@ export const selectDeckValidById = createSelector(
: { valid: false, errors: [] };
},
);
+
+export const selectForbiddenCardsById = createSelector(
+ selectDeckValidById,
+ (deckValidation) => {
+ const forbidden = deckValidation.errors.find((x) => x.type === "FORBIDDEN");
+ if (!forbidden) return [];
+ return (forbidden as ForbiddenCardError).details;
+ },
+);
+
+export const selectShowIgnoreDeckLimitSlotsById = createSelector(
+ selectActiveDeckById,
+ (_: StoreState, __: Id, ___: boolean | undefined, card: Card) => card,
+ (deck, card) => {
+ if (!deck) return false;
+
+ const traits = card.real_traits ?? "";
+ const investigator = deck.investigatorBack.card.code;
+
+ return (
+ // cards that are already ignored.
+ !!deck.ignoreDeckLimitSlots?.[card.code] ||
+ // parallel agnes & spells
+ (investigator === SPECIAL_CARD_CODES.PARALLEL_AGNES &&
+ traits.includes("Spell")) ||
+ // parallel skids & gambit / fortune
+ (investigator === SPECIAL_CARD_CODES.PARALLEL_SKIDS &&
+ (traits.includes("Gambit") || traits.includes("Fortunes"))) ||
+ // ace of rods
+ card.code === SPECIAL_CARD_CODES.ACE_OF_RODS
+ );
+ },
+);
diff --git a/src/store/selectors/decks.ts b/src/store/selectors/decks.ts
index fb2077e0..af9deb75 100644
--- a/src/store/selectors/decks.ts
+++ b/src/store/selectors/decks.ts
@@ -1,21 +1,12 @@
import { createSelector } from "reselect";
-import type { DisplayDeck } from "@/store/lib/deck-grouping";
-import { groupDeckCardsByType } from "@/store/lib/deck-grouping";
import { resolveDeck } from "@/store/lib/resolve-deck";
-import { SPECIAL_CARD_CODES } from "@/utils/constants";
-import { applyDeckEdits } from "../lib/deck-edits";
-import type {
- DeckValidationResult,
- ForbiddenCardError,
-} from "../lib/deck-validation";
+import type { DeckValidationResult } from "../lib/deck-validation";
import { validateDeck } from "../lib/deck-validation";
import { sortAlphabetical } from "../lib/sorting";
import type { ResolvedCard, ResolvedDeck } from "../lib/types";
-import type { Card } from "../services/queries.types";
import type { StoreState } from "../slices";
-import { type Slot, mapTabToSlot } from "../slices/deck-view.types";
type LocalDeck = {
deck: ResolvedDeck;
@@ -64,121 +55,3 @@ export const selectLocalDecks = createSelector(
return resolvedDecks;
},
);
-
-export const selectResolvedDeck = createSelector(
- (state: StoreState) => state.metadata,
- (state: StoreState) => state.lookupTables,
- (state: StoreState) => state.data.decks,
- (state: StoreState) => state.deckView,
- (metadata, lookupTables, decks, deckView) => {
- if (!deckView || !decks[deckView.id]) return undefined;
-
- console.time("[perf] select_resolved_deck");
-
- const deck = applyDeckEdits(decks[deckView.id], deckView, metadata);
- const resolvedDeck = resolveDeck(metadata, lookupTables, deck, true);
-
- console.timeEnd("[perf] select_resolved_deck");
- return resolvedDeck;
- },
-);
-
-export const selectActiveDeck = createSelector(
- selectResolvedDeck,
- (state: StoreState) => state.metadata,
- (state: StoreState) => state.lookupTables,
- (state: StoreState) => state.settings.collection,
- (state: StoreState) => state.settings.showAllCards,
- (resolvedDeck, metadata, lookupTables, collectionSetting, showAll) => {
- if (!resolvedDeck) return undefined;
-
- const displayDeck = resolvedDeck as DisplayDeck;
- const { groupings, bonded, ownershipCounts } = groupDeckCardsByType(
- resolvedDeck,
- metadata,
- lookupTables,
- collectionSetting,
- showAll,
- );
-
- displayDeck.ownershipCounts = ownershipCounts;
- displayDeck.groups = groupings;
-
- displayDeck.bondedSlots = bonded.reduce>(
- (acc, curr) => {
- acc[curr.code] = curr.quantity;
- return acc;
- },
- {},
- );
-
- return displayDeck;
- },
-);
-
-export const selectDeckValid = createSelector(
- selectResolvedDeck,
- (state: StoreState) => state.lookupTables,
- (state: StoreState) => state.metadata,
- (deck, lookupTables, metadata) =>
- deck
- ? validateDeck(deck, { lookupTables, metadata } as StoreState)
- : { valid: false, errors: [] },
-);
-
-export const selectForbiddenCards = createSelector(
- selectDeckValid,
- (deckValidation) => {
- const forbidden = deckValidation.errors.find((x) => x.type === "FORBIDDEN");
- if (!forbidden) return [];
- return (forbidden as ForbiddenCardError).details;
- },
-);
-
-export function selectCardQuantitiesForSlot(
- state: StoreState,
- slot: Slot | "bondedSlots",
-) {
- if (!state.deckView) return undefined;
-
- const activeDeck = selectActiveDeck(state);
- if (!activeDeck) return undefined;
-
- return activeDeck[slot];
-}
-
-export function selectCardQuantities(state: StoreState) {
- if (!state.deckView) return undefined;
-
- const slot = mapTabToSlot(state.deckView.activeTab);
- return selectCardQuantitiesForSlot(state, slot) ?? undefined;
-}
-
-export const selectCurrentTab = (state: StoreState) => {
- if (!state.deckView) return "slots";
- return state.deckView.activeTab;
-};
-
-export const selectShowIgnoreDeckLimitSlots = (
- state: StoreState,
- card: Card,
-) => {
- const activeDeck = selectActiveDeck(state);
- if (!activeDeck) return false;
-
- const traits = card.real_traits ?? "";
- const investigator = activeDeck.investigatorBack.card.code;
-
- return (
- // cards that are already ignored.
- !!activeDeck.ignoreDeckLimitSlots?.[card.code] ||
- // parallel agnes & spells
- (investigator === SPECIAL_CARD_CODES.PARALLEL_AGNES &&
- traits.includes("Spell")) ||
- // parallel skids & gambit / fortune
- (investigator === SPECIAL_CARD_CODES.PARALLEL_SKIDS &&
- (traits.includes("Gambit") || traits.includes("Fortunes"))) ||
- // ace of rods
- card.code === SPECIAL_CARD_CODES.ACE_OF_RODS
- );
-};
diff --git a/src/store/selectors/lists.ts b/src/store/selectors/lists.ts
index 56ae990d..363a1aeb 100644
--- a/src/store/selectors/lists.ts
+++ b/src/store/selectors/lists.ts
@@ -10,6 +10,7 @@ import {
} from "../lib/sorting";
import type { Card, Cycle, Pack } from "../services/queries.types";
import type { StoreState } from "../slices";
+import type { Id } from "../slices/data.types";
import type {
AssetFilter,
CostFilter,
@@ -19,7 +20,7 @@ import type {
SelectFilter,
SkillIconsFilter,
} from "../slices/lists.types";
-import { selectActiveDeck } from "./decks";
+import { selectResolvedDeckById } from "./deck-view";
export const selectActiveList = (state: StoreState) => {
const active = state.activeList;
@@ -402,7 +403,11 @@ export const selectTabooSetChanges = createSelector(
},
);
-export const selectCanonicalTabooSetId = (state: StoreState) => {
+export const selectCanonicalTabooSetId = (
+ state: StoreState,
+ deckId?: Id,
+ applyEdits?: boolean,
+) => {
const filters = selectActiveListFilters(state);
const filterId = filters.findIndex((f) => f === "tabooSet");
@@ -411,8 +416,10 @@ export const selectCanonicalTabooSetId = (state: StoreState) => {
: undefined;
if (typeof filterValue === "number") return filterValue;
- const activeDeck = selectActiveDeck(state);
- if (activeDeck) return activeDeck.taboo_id;
+ if (deckId) {
+ const resolvedDeck = selectResolvedDeckById(state, deckId, applyEdits);
+ if (resolvedDeck) return resolvedDeck.tabooSet?.id;
+ }
return state.settings.tabooSetId;
};
diff --git a/src/store/selectors/shared.ts b/src/store/selectors/shared.ts
index 7490aad7..aaf7006e 100644
--- a/src/store/selectors/shared.ts
+++ b/src/store/selectors/shared.ts
@@ -25,11 +25,3 @@ export const selectCardOwnedCount = createSelector(
};
},
);
-
-export const selectNeedsConfirmation = (state: StoreState) => {
- if (state.deckView?.dirty) {
- return "This operation will revert the changes made to the deck. Do you want to continue?";
- }
-
- return undefined;
-};
diff --git a/src/store/slices/data.types.ts b/src/store/slices/data.types.ts
index 8e92d7a4..5e0fa5aa 100644
--- a/src/store/slices/data.types.ts
+++ b/src/store/slices/data.types.ts
@@ -2,7 +2,7 @@ export type Slots = {
[code: string]: number;
};
-type Id = number | string;
+export type Id = number | string;
export type Deck = {
id: Id;
diff --git a/src/store/slices/deck-edits.spec.ts b/src/store/slices/deck-edits.spec.ts
new file mode 100644
index 00000000..07360c39
--- /dev/null
+++ b/src/store/slices/deck-edits.spec.ts
@@ -0,0 +1,88 @@
+import { afterEach } from "node:test";
+import { beforeAll, beforeEach, describe, expect, it } from "vitest";
+import type { StoreApi } from "zustand";
+
+import deckExtraSlots from "@/test/fixtures/decks/extra_slots.json";
+import { getMockStore } from "@/test/get-mock-store";
+
+import type { StoreState } from ".";
+import { selectResolvedDeckById } from "../selectors/deck-view";
+
+describe("deck-view slice", () => {
+ let store: StoreApi;
+
+ beforeAll(async () => {
+ store = await getMockStore();
+ });
+
+ describe("updateCardQuantity", () => {
+ beforeEach(() => {
+ store.setState({
+ data: {
+ decks: {
+ "deck-id": deckExtraSlots,
+ },
+ history: {
+ "deck-id": [],
+ },
+ },
+ });
+ });
+
+ afterEach(async () => {
+ store = await getMockStore();
+ });
+
+ it("increments the quantity of a card", () => {
+ const state = store.getState();
+ state.updateCardQuantity("deck-id", "01000", 1, "slots", "increment");
+ expect(
+ selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"],
+ ).toEqual(2);
+ });
+
+ it("decrements the quantity of a card", () => {
+ const state = store.getState();
+ state.updateCardQuantity("deck-id", "01000", -1, "slots", "increment");
+ expect(
+ selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"],
+ ).toEqual(0);
+ });
+
+ it("sets the quantity of a card", () => {
+ const state = store.getState();
+ state.updateCardQuantity("deck-id", "01000", 5, "slots", "set");
+ expect(
+ selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"],
+ ).toEqual(5);
+ });
+
+ it("does not set the quantity of a card to a negative value", () => {
+ const state = store.getState();
+ state.updateCardQuantity("deck-id", "01000", -5, "slots", "set");
+ state.updateCardQuantity("deck-id", "01000", -5, "slots", "increment");
+ expect(
+ selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"],
+ ).toEqual(0);
+ });
+
+ it("does not set the quantity of a card exceeding the limit", () => {
+ const state = store.getState();
+ state.updateCardQuantity("deck-id", "06021", 5, "slots", "set");
+ state.updateCardQuantity("deck-id", "06021", 5, "slots", "increment");
+ expect(
+ selectResolvedDeckById(store.getState(), "deck-id")?.slots["06021"],
+ ).toEqual(3);
+ });
+
+ it("adjusts cards in side slots", () => {
+ const state = store.getState();
+ state.updateCardQuantity("deck-id", "06021", 1, "sideSlots", "increment");
+ expect(
+ selectResolvedDeckById(store.getState(), "deck-id")?.sideSlots?.[
+ "06021"
+ ],
+ ).toEqual(1);
+ });
+ });
+});
diff --git a/src/store/slices/deck-edits.ts b/src/store/slices/deck-edits.ts
new file mode 100644
index 00000000..d793531b
--- /dev/null
+++ b/src/store/slices/deck-edits.ts
@@ -0,0 +1,192 @@
+import type { StateCreator } from "zustand";
+
+import { capitalize } from "@/utils/formatting";
+
+import type { StoreState } from ".";
+import { resolveDeck } from "../lib/resolve-deck";
+import type { Id } from "./data.types";
+import { type DeckEditsSlice, mapTabToSlot } from "./deck-edits.types";
+
+function currentEdits(state: StoreState, deckId: Id) {
+ return (
+ state.deckEdits[deckId] ?? {
+ quantities: {},
+ meta: {},
+ customizations: {},
+ }
+ );
+}
+
+export const createDeckEditsSlice: StateCreator<
+ StoreState,
+ [],
+ [],
+ DeckEditsSlice
+> = (set, get) => ({
+ deckEdits: {},
+
+ discardEdits(deckId) {
+ const state = get();
+ const deckEdits = { ...state.deckEdits };
+ delete deckEdits[deckId];
+ set({ deckEdits });
+ },
+
+ updateCardQuantity(deckId, code, quantity, tab, mode = "increment") {
+ const state = get();
+
+ const edits = currentEdits(state, deckId);
+
+ const targetTab = tab || "slots";
+ const slot = mapTabToSlot(targetTab);
+
+ const card = state.metadata.cards[code];
+ const limit = card.deck_limit ?? card.quantity;
+
+ const slotEdits = edits.quantities[slot];
+
+ const deck = resolveDeck(
+ state.metadata,
+ state.lookupTables,
+ state.data.decks[deckId],
+ false,
+ );
+ const slots = deck[slot] ?? {};
+
+ const value = slotEdits?.[code] ?? slots?.[code] ?? 0;
+
+ const newValue =
+ mode === "increment"
+ ? Math.max(value + quantity, 0)
+ : Math.max(Math.min(quantity, limit), 0);
+
+ if (mode === "increment" && value + quantity > limit) return;
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...edits,
+ quantities: {
+ ...edits.quantities,
+ [slot]: {
+ ...edits.quantities[slot],
+ [code]: newValue,
+ },
+ },
+ },
+ },
+ });
+ },
+
+ updateTabooId(deckId, value) {
+ const state = get();
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...currentEdits(state, deckId),
+ tabooId: value,
+ },
+ },
+ });
+ },
+ updateDescription(deckId, value) {
+ const state = get();
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...currentEdits(state, deckId),
+ description_md: value,
+ },
+ },
+ });
+ },
+ updateName(deckId, value) {
+ const state = get();
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...currentEdits(state, deckId),
+ name: value,
+ },
+ },
+ });
+ },
+ updateMetaProperty(deckId, key, value) {
+ const state = get();
+ const edits = currentEdits(state, deckId);
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...edits,
+ meta: {
+ ...edits.meta,
+ [key]: value,
+ },
+ },
+ },
+ });
+ },
+
+ updateInvestigatorSide(deckId, side, code) {
+ const state = get();
+ const edits = currentEdits(state, deckId);
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...edits,
+ [`investigator${capitalize(side)}`]: code,
+ },
+ },
+ });
+ },
+
+ updateCustomization(deckId, code, index, patch) {
+ const state = get();
+ const edits = currentEdits(state, deckId);
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...edits,
+ customizations: {
+ ...edits.customizations,
+ [code]: {
+ ...edits.customizations?.[code],
+ [index]: {
+ ...edits.customizations?.[code]?.[index],
+ ...patch,
+ },
+ },
+ },
+ },
+ },
+ });
+ },
+
+ updateTags(deckId, value) {
+ const state = get();
+ const edits = currentEdits(state, deckId);
+
+ set({
+ deckEdits: {
+ ...state.deckEdits,
+ [deckId]: {
+ ...edits,
+ tags: value,
+ },
+ },
+ });
+ },
+});
diff --git a/src/store/slices/deck-view.types.ts b/src/store/slices/deck-edits.types.ts
similarity index 53%
rename from src/store/slices/deck-view.types.ts
rename to src/store/slices/deck-edits.types.ts
index 25df5e29..5e22f6d1 100644
--- a/src/store/slices/deck-view.types.ts
+++ b/src/store/slices/deck-edits.types.ts
@@ -1,5 +1,7 @@
import type { DeckOptionSelectType } from "@/store/services/queries.types";
+import type { Id } from "./data.types";
+
export type Slot =
| "slots"
| "sideSlots"
@@ -40,72 +42,66 @@ export type CustomizationEdit = {
};
export type EditState = {
- edits: {
- customizations: {
- [code: string]: {
- [id: number]: CustomizationEdit;
- };
- };
- meta: {
- [key: string]: string | null;
+ customizations: {
+ [code: string]: {
+ [id: number]: CustomizationEdit;
};
- quantities: {
- extraSlots?: Record;
- ignoreDeckLimitSlots?: Record;
- sideSlots?: Record;
- slots?: Record;
- };
- name?: string | null;
- description_md?: string | null;
- tags?: string | null;
- tabooId?: number | null;
- investigatorFront?: string | null;
- investigatorBack?: string | null;
};
- dirty: boolean;
- activeTab: Tab;
- showUnusableCards: boolean;
+ meta: {
+ [key: string]: string | null;
+ };
+ quantities: {
+ extraSlots?: Record;
+ ignoreDeckLimitSlots?: Record;
+ sideSlots?: Record;
+ slots?: Record;
+ };
+ name?: string | null;
+ description_md?: string | null;
+ tags?: string | null;
+ tabooId?: number | null;
+ investigatorFront?: string | null;
+ investigatorBack?: string | null;
};
-export type DeckViewState = {
- id: string;
-} & EditState;
-
-export type DeckViewSlice = {
- deckView: DeckViewState | null;
+export type EditsState = {
+ [id: Id]: EditState;
+};
- setActiveDeck(id?: string): void;
+export type DeckEditsSlice = {
+ deckEdits: EditsState;
- updateActiveTab(value: string): void;
+ discardEdits(deckId: Id): void;
updateCardQuantity(
+ deckId: Id,
code: string,
quantity: number,
slot?: Slot,
mode?: "increment" | "set",
): void;
- updateTabooId(value: number | null): void;
+ updateTabooId(deckId: Id, value: number | null): void;
- updateInvestigatorSide(side: string, code: string): void;
+ updateInvestigatorSide(deckId: Id, side: string, code: string): void;
updateCustomization(
+ deckId: Id,
code: string,
index: number,
edit: CustomizationEdit,
): void;
updateMetaProperty(
+ deckId: Id,
key: string,
value: string | null,
type: DeckOptionSelectType,
): void;
- updateName(value: string): void;
-
- updateDescription(value: string): void;
+ updateName(deckId: Id, value: string): void;
- updateTags(value: string): void;
+ updateDescription(deckId: Id, value: string): void;
- updateShowUnusableCards(value: boolean): void;
+ updateTags(deckId: Id, value: string): void;
};
diff --git a/src/store/slices/deck-view.spec.ts b/src/store/slices/deck-view.spec.ts
deleted file mode 100644
index 7809bf9b..00000000
--- a/src/store/slices/deck-view.spec.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { afterEach } from "node:test";
-import { beforeAll, beforeEach, describe, expect, it } from "vitest";
-import type { StoreApi } from "zustand";
-
-import deckExtraSlots from "@/test/fixtures/decks/extra_slots.json";
-import { getMockStore } from "@/test/get-mock-store";
-
-import type { StoreState } from ".";
-import { selectActiveDeck } from "../selectors/decks";
-
-describe("deck-view slice", () => {
- let store: StoreApi;
-
- beforeAll(async () => {
- store = await getMockStore();
- });
-
- describe("updateCardQuantity", () => {
- beforeEach(() => {
- store.setState({
- data: {
- decks: {
- "deck-id": deckExtraSlots,
- },
- history: {
- "deck-id": [],
- },
- },
- });
- });
-
- afterEach(async () => {
- store = await getMockStore();
- });
-
- it("throws an error if there is no active deck", () => {
- store.setState({
- deckView: null,
- });
-
- expect(() => {
- store.getState().updateCardQuantity("01000", 1, "slots");
- }).toThrowErrorMatchingInlineSnapshot(
- `[Error: assertion failed: trying to edit deck, but state does not have an active deck.]`,
- );
- });
-
- it("throws an error if the active deck does not exist", () => {
- store.getState().setActiveDeck("non-existent-deck");
-
- expect(() => {
- store.getState().updateCardQuantity("01000", 1, "slots");
- }).toThrowErrorMatchingInlineSnapshot(
- `[Error: assertion failed: trying to edit deck, but deck does not exist.]`,
- );
- });
-
- it("increments the quantity of a card", () => {
- const state = store.getState();
- state.setActiveDeck("deck-id");
- state.updateCardQuantity("01000", 1, "slots", "increment");
- expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(2);
- });
-
- it("decrements the quantity of a card", () => {
- const state = store.getState();
- state.setActiveDeck("deck-id");
- state.updateCardQuantity("01000", -1, "slots", "increment");
- expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(0);
- });
-
- it("sets the quantity of a card", () => {
- const state = store.getState();
- state.setActiveDeck("deck-id");
- state.updateCardQuantity("01000", 5, "slots", "set");
- expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(5);
- });
-
- it("does not set the quantity of a card to a negative value", () => {
- const state = store.getState();
- state.setActiveDeck("deck-id");
- state.updateCardQuantity("01000", -5, "slots", "set");
- state.updateCardQuantity("01000", -5, "slots", "increment");
- expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(0);
- });
-
- it("does not set the quantity of a card exceeding the limit", () => {
- const state = store.getState();
- state.setActiveDeck("deck-id");
- state.updateCardQuantity("06021", 5, "slots", "set");
- state.updateCardQuantity("06021", 5, "slots", "increment");
- expect(selectActiveDeck(store.getState())?.slots["06021"]).toEqual(3);
- });
-
- it("adjusts cards in side slots", () => {
- const state = store.getState();
- state.setActiveDeck("deck-id");
- state.updateCardQuantity("06021", 1, "sideSlots", "increment");
- expect(selectActiveDeck(store.getState())?.sideSlots?.["06021"]).toEqual(
- 1,
- );
- });
- });
-});
diff --git a/src/store/slices/deck-view.ts b/src/store/slices/deck-view.ts
deleted file mode 100644
index 2c3f9f04..00000000
--- a/src/store/slices/deck-view.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import type { StateCreator } from "zustand";
-
-import { assert } from "@/utils/assert";
-
-import type { StoreState } from ".";
-import { resolveDeck } from "../lib/resolve-deck";
-import type { EditState } from "./deck-view.types";
-import { type DeckViewSlice, isTab, mapTabToSlot } from "./deck-view.types";
-
-export const createDeckViewSlice: StateCreator<
- StoreState,
- [],
- [],
- DeckViewSlice
-> = (set, get) => ({
- deckView: null,
-
- setActiveDeck(activeDeckId) {
- if (!activeDeckId) {
- set({ deckView: null });
- return;
- }
- set({
- deckView: {
- activeTab: "slots",
- showUnusableCards: false,
- id: activeDeckId,
- dirty: false,
- edits: {
- meta: {},
- quantities: {},
- customizations: {},
- },
- },
- });
- },
-
- updateActiveTab(value) {
- const state = get();
-
- assertInEditMode(state);
- assert(isTab(value), `invalid tab value: ${value}`);
-
- set({
- deckView: {
- ...state.deckView,
- activeTab: value,
- },
- });
- },
-
- updateCardQuantity(code, quantity, tab, mode = "increment") {
- const state = get();
- assertInEditMode(state);
-
- const targetTab = tab || state.deckView.activeTab || "slots";
-
- const slot = mapTabToSlot(targetTab);
-
- const card = state.metadata.cards[code];
- const limit = card.deck_limit ?? card.quantity;
-
- const slotEdits = state.deckView.edits.quantities[slot];
-
- const deck = resolveDeck(
- state.metadata,
- state.lookupTables,
- state.data.decks[state.deckView.id],
- false,
- );
- const slots = deck[slot] ?? {};
-
- const value = slotEdits?.[code] ?? slots?.[code] ?? 0;
-
- const newValue =
- mode === "increment"
- ? Math.max(value + quantity, 0)
- : Math.max(Math.min(quantity, limit), 0);
-
- if (mode === "increment" && value + quantity > limit) return;
-
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- quantities: {
- ...state.deckView.edits.quantities,
- [slot]: {
- ...state.deckView.edits.quantities[slot],
- [code]: newValue,
- },
- },
- },
- },
- });
- },
-
- updateTabooId(value) {
- const state = get();
- assertInEditMode(state);
-
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- tabooId: value,
- },
- },
- });
- },
- updateDescription(value) {
- const state = get();
- assertInEditMode(state);
-
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- description_md: value,
- },
- },
- });
- },
- updateName(value) {
- const state = get();
- assertInEditMode(state);
-
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- name: value,
- },
- },
- });
- },
- updateMetaProperty(key, value) {
- const state = get();
- assertInEditMode(state);
-
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- meta: {
- ...state.deckView.edits.meta,
- [key]: value,
- },
- },
- },
- });
- },
-
- updateInvestigatorSide(side, code) {
- const state = get();
- assertInEditMode(state);
-
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- investigatorBack:
- side === "back" ? code : state.deckView.edits.investigatorBack,
- investigatorFront:
- side === "front" ? code : state.deckView.edits.investigatorFront,
- },
- },
- });
- },
-
- updateCustomization(code, index, edit) {
- const state = get();
- assertInEditMode(state);
-
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- customizations: {
- ...state.deckView.edits.customizations,
- [code]: {
- ...state.deckView.edits.customizations[code],
- [index]: {
- ...state.deckView.edits.customizations[code]?.[index],
- ...edit,
- },
- },
- },
- },
- },
- });
- },
-
- updateTags(value) {
- const state = get();
- assertInEditMode(state);
- set({
- deckView: {
- ...state.deckView,
- dirty: true,
- edits: {
- ...state.deckView.edits,
- tags: value,
- },
- },
- });
- },
-
- updateShowUnusableCards(showUnusableCards) {
- const state = get();
- assertInEditMode(state);
-
- set({
- deckView: {
- ...state.deckView,
- showUnusableCards,
- },
- });
- },
-});
-
-function assertInEditMode(state: StoreState): asserts state is StoreState & {
- deckView: EditState;
-} {
- assert(
- state.deckView,
- "trying to edit deck, but state does not have an active deck.",
- );
-
- assert(
- state.data.decks[state.deckView.id],
- `trying to edit deck, but deck does not exist.`,
- );
-}
diff --git a/src/store/slices/index.ts b/src/store/slices/index.ts
index a53fce83..f0c37fa5 100644
--- a/src/store/slices/index.ts
+++ b/src/store/slices/index.ts
@@ -1,6 +1,6 @@
import type { DataSlice } from "./data.types";
import type { DeckCreateSlice } from "./deck-create.types";
-import type { DeckViewSlice } from "./deck-view.types";
+import type { DeckEditsSlice } from "./deck-edits.types";
import type { ListsSlice } from "./lists.types";
import type { LookupTablesSlice } from "./lookup-tables.types";
import type { MetadataSlice } from "./metadata.types";
@@ -15,5 +15,5 @@ export type StoreState = SharedSlice &
UISlice &
SettingsSlice &
DataSlice &
- DeckViewSlice &
+ DeckEditsSlice &
DeckCreateSlice;
diff --git a/src/store/slices/shared.ts b/src/store/slices/shared.ts
index dd371485..5d957537 100644
--- a/src/store/slices/shared.ts
+++ b/src/store/slices/shared.ts
@@ -145,31 +145,27 @@ export const createSharedSlice: StateCreator<
return true;
},
- saveDeck() {
+ saveDeck(deckId) {
const state = get();
- if (!state.deckView) {
+ const edits = state.deckEdits[deckId];
+
+ if (!edits) {
console.warn("Tried to save deck but not in edit mode.");
return;
}
- const deck = state.data.decks[state.deckView.id];
+ const deck = state.data.decks[deckId];
if (!deck) return;
- const nextDeck = applyDeckEdits(deck, state.deckView, state.metadata, true);
+ const nextDeck = applyDeckEdits(deck, edits, state.metadata, true);
nextDeck.date_update = new Date().toISOString();
+ const deckEdits = { ...state.deckEdits };
+ delete deckEdits[deckId];
+
set({
- deckView: {
- ...state.deckView,
- activeTab: state.deckView.activeTab,
- dirty: false,
- edits: {
- meta: {},
- quantities: {},
- customizations: {},
- },
- },
+ deckEdits,
data: {
...state.data,
decks: {
diff --git a/src/store/slices/shared.types.ts b/src/store/slices/shared.types.ts
index 043f3d12..11b15793 100644
--- a/src/store/slices/shared.types.ts
+++ b/src/store/slices/shared.types.ts
@@ -4,6 +4,8 @@ import type {
MetadataResponse,
} from "@/store/services/queries";
+import type { Id } from "./data.types";
+
export type SharedSlice = {
init(
queryMetadata: () => Promise,
@@ -14,5 +16,5 @@ export type SharedSlice = {
createDeck(): string | number;
- saveDeck(): void;
+ saveDeck(deckId: Id): void;
};
diff --git a/src/utils/custom-equal-selector.ts b/src/utils/custom-equal-selector.ts
index 2d9c7d93..a3cdf3dc 100644
--- a/src/utils/custom-equal-selector.ts
+++ b/src/utils/custom-equal-selector.ts
@@ -7,7 +7,8 @@ export const createCustomEqualSelector = (equalFn: EqualityFn) =>
export const createDebugSelector = createSelectorCreator(lruMemoize, {
equalityCheck: (previousVal, currentVal) => {
const rv = currentVal === previousVal;
- if (!rv) console.log("Selector param value changed", currentVal);
+ if (!rv)
+ console.log("Selector param value changed: ", previousVal, currentVal);
return rv;
},
});
diff --git a/src/utils/use-deck-id.tsx b/src/utils/use-deck-id.tsx
new file mode 100644
index 00000000..ab65b5d2
--- /dev/null
+++ b/src/utils/use-deck-id.tsx
@@ -0,0 +1,56 @@
+import type React from "react";
+import { createContext, useContext, useMemo } from "react";
+
+import type { Id } from "@/store/slices/data.types";
+
+import { assert } from "./assert";
+
+interface DeckContextType {
+ deckId?: Id;
+ canEdit?: boolean;
+}
+
+type DeckContextTypeChecked = {
+ deckId: Id;
+ canEdit?: boolean;
+};
+
+function isDeckContextTypeChecked(
+ context: DeckContextType,
+): context is DeckContextTypeChecked {
+ return context.deckId !== undefined;
+}
+
+const DeckIdContext = createContext(undefined);
+
+type Props = DeckContextType & {
+ children: React.ReactNode;
+};
+
+export function DeckIdProvider({ deckId, canEdit, children }: Props) {
+ const value = useMemo(
+ () => ({
+ deckId,
+ canEdit,
+ }),
+ [deckId, canEdit],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useDeckId() {
+ const context = useContext(DeckIdContext);
+ return context ?? { deckId: undefined, canEdit: false };
+}
+
+export function useDeckIdChecked(): DeckContextTypeChecked {
+ const context = useDeckId();
+ assert(
+ isDeckContextTypeChecked(context),
+ "expected to be defined in a parent DeckIdProvider",
+ );
+ return context;
+}
diff --git a/src/utils/use-location-with-confirm.ts b/src/utils/use-location-with-confirm.ts
deleted file mode 100644
index ed8bc07f..00000000
--- a/src/utils/use-location-with-confirm.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useCallback } from "react";
-import type { BaseLocationHook } from "wouter";
-import { useBrowserLocation } from "wouter/use-browser-location";
-
-import { useStore } from "@/store";
-import { selectNeedsConfirmation } from "@/store/selectors/shared";
-
-export const useBrowserLocationWithConfirmation: BaseLocationHook = () => {
- const [location, setLocation] = useBrowserLocation();
- const needsConfirm = useStore(selectNeedsConfirmation);
-
- const onLocationChange = useCallback(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (newLocation: string, options: any) => {
- const perfomNavigation =
- needsConfirm && !options?.state?.confirm
- ? window.confirm(needsConfirm)
- : true;
- if (perfomNavigation) setLocation(newLocation);
- },
- [needsConfirm, setLocation],
- );
-
- return [location, onLocationChange];
-};
diff --git a/src/utils/use-sync-active-deck-id.ts b/src/utils/use-sync-active-deck-id.ts
deleted file mode 100644
index 4f9459af..00000000
--- a/src/utils/use-sync-active-deck-id.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useEffect } from "react";
-import { usePathname } from "wouter/use-browser-location";
-
-import { useCardModalContext } from "@/components/card-modal/card-modal-context";
-import { useStore } from "@/store";
-
-export function useSyncActiveDeckId() {
- const pathname = usePathname();
- const modalContext = useCardModalContext();
-
- const setActiveDeck = useStore((state) => state.setActiveDeck);
-
- useEffect(() => {
- modalContext.setClosed();
-
- if (pathname.startsWith("/deck/edit/")) {
- const id = pathname.split("/").at(-1);
- setActiveDeck(id);
- } else {
- setActiveDeck(undefined);
- }
- }, [setActiveDeck, pathname, modalContext]);
-}