Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ This project uses the following dependencies:
- [TypeScript](https://www.typescriptlang.org/), rather than vanilla JavaScript.
- [Vite](https://vite.dev/) for local development and building.
- [Vitest](https://vitest.dev/) for unit tests.
- [Vue Redux](https://vue-redux.js.org/) for state management.
- [Pinia](https://pinia.vuejs.org/) for state management.
- [Sass](https://sass-lang.com/) (via the [sass](https://www.npmjs.com/package/sass) package) for styling. Specifically, we use SCSS files.
- [ESLint](https://eslint.org) and [Vue ESLint](https://eslint.vuejs.org/user-guide/) for linting.

Expand Down
2,020 changes: 1,427 additions & 593 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@
"lint": "eslint ."
},
"dependencies": {
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@reduxjs/toolkit": "^2.6.1",
"@reduxjs/vue-redux": "^1.0.1",
"@pinia/testing": "^1.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/vue": "^8.1.0",
"@types/js-levenshtein": "^1.1.3",
Expand Down
5 changes: 2 additions & 3 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@
import { ref } from "vue";
import ThemeButton from "./components/ThemeButton.vue";
import ViewSettingsButton from "./components/ViewSettingsButton.vue";
import { useAppSelector } from "./store/hooks";
import { RouterView } from "vue-router";
import SettingsScreen from "./pages/SettingsScreen.vue";
import { useConfigRefs } from "./store";

const theme = useAppSelector((state) => state.config.theme);
const { theme, viewConfigScreen } = useConfigRefs();
const currentYear = ref(new Date().getFullYear());
const viewConfigScreen = useAppSelector((state) => state.config.viewConfigScreen);
</script>

<template>
Expand Down
26 changes: 14 additions & 12 deletions src/components/GameScreen.vue
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
<script setup lang="ts">
import { Outcome } from "../models/outcome";
import { fetchNextCard, LoadingStatus, setGuess, useAppDispatch, useAppSelector } from "../store";
import { LoadingStatus, useGameRefs, useGameStore } from "../store";
import CardArt from "./CardArt.vue";
import GuessInput from "./GuessInput.vue";
import { isGuessOk } from "../utils/guess";
import GuessFeedback from "./GuessFeedback.vue";
import LoadingHammer from "./LoadingHammer.vue";

const dispatch = useAppDispatch();
const nextCardStatus = useAppSelector((state) => state.game.nextCardStatus);
const card = useAppSelector((state) => state.game.card);
const score = useAppSelector((state) => state.game.score);
const prevCard = useAppSelector((state) => state.game.previousCard);
const prevGuess = useAppSelector((state) => state.game.guess);
const query = useAppSelector((state) => state.game.query);
const gameLoadStatus = useAppSelector((state) => state.game.status);
const {
nextCardStatus,
card,
score,
previousCard: prevCard,
guess: prevGuess,
query,
status: gameLoadStatus,
} = useGameRefs();
const { fetchNextCard, setGuess } = useGameStore();

const loadNextCard = () => {
if (!query.value) {
throw Error("Somehow, no game query is loaded.");
}

dispatch(fetchNextCard({ query: query.value, previousOracleId: card.value?.oracle_id }));
fetchNextCard(card.value?.oracle_id);
};

const skip = () => {
dispatch(setGuess({ name: "", outcome: Outcome.Skip }));
setGuess({ name: "", outcome: Outcome.Skip });
loadNextCard();
};

Expand All @@ -40,7 +42,7 @@ const submitGuess = (guess: string) => {
}

const outcome = isGuessOk(guess, card.value) ? Outcome.Correct : Outcome.Incorrect;
dispatch(setGuess({ name: guess, outcome }));
setGuess({ name: guess, outcome });
loadNextCard();
};
</script>
Expand Down
4 changes: 2 additions & 2 deletions src/components/GuessInput.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, useId, useTemplateRef, watch, type Directive } from "vue";
import { useAppSelector } from "../store";
import { useConfigRefs } from "../store";
import GuessAutocompleteList from "./GuessAutocompleteList.vue";
import { KeyCode } from "../utils/keyboard";
import { getActiveDescendent } from "./GuessAutocompleteConfig";
Expand All @@ -16,6 +16,7 @@ type Emits = {
submit: [value: string];
};

const { autocomplete: acEnabled } = useConfigRefs();
const { disabled } = defineProps<Props>();
const emit = defineEmits<Emits>();

Expand All @@ -27,7 +28,6 @@ const form = useTemplateRef("form");
const input = useTemplateRef("input");
const guess = ref("");
const focused = ref(false);
const acEnabled = useAppSelector((state) => state.config.autocomplete);
const acOptions = useAutocomplete(guess);
const acKeyboardFocusIndex = ref(AC_NO_SELECTION);

Expand Down
11 changes: 4 additions & 7 deletions src/components/ThemeButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,18 @@
import { Theme } from "../models/theme";
import MoonSolidSvg from "./Svg/MoonSolidSvg.vue";
import SunOutlineSvg from "./Svg/SunOutlineSvg.vue";
import { useAppDispatch, useAppSelector, toggleTheme } from "../store";
import { useConfigRefs, useConfigStore } from "../store";
import { capitalize } from "../utils/string";

const dispatch = useAppDispatch();
const theme = useAppSelector((state) => state.config.theme);
const toggle = () => {
dispatch(toggleTheme(theme.value));
};
const { toggleTheme } = useConfigStore();
const { theme } = useConfigRefs();
</script>

<template>
<button
type="button"
:class="{ light: theme === Theme.Light, dark: theme === Theme.Dark }"
@click="toggle"
@click="toggleTheme"
>
<div class="icon sun" aria-hidden="true">
<SunOutlineSvg />
Expand Down
8 changes: 3 additions & 5 deletions src/components/ViewSettingsButton.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
<script setup lang="ts">
import CogOutlineSvg from "./Svg/CogOutlineSvg.vue";
import CogSolidSvg from "./Svg/CogSolidSvg.vue";
import { setViewConfigScreen, useAppDispatch, useAppSelector } from "../store";

const dispatch = useAppDispatch();
const isViewingSettings = useAppSelector((state) => state.config.viewConfigScreen);
import { useConfigRefs } from "../store";

const { viewConfigScreen: isViewingSettings } = useConfigRefs();
const click = () => {
dispatch(setViewConfigScreen(!isViewingSettings.value));
isViewingSettings.value = !isViewingSettings.value;
};
</script>

Expand Down
44 changes: 18 additions & 26 deletions src/components/__test__/ThemeButton.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import ThemeButton from "../ThemeButton.vue";
import { toggleTheme, useAppDispatch, useAppSelector } from "../../store";
import { nextTick, ref, type Ref } from "vue";
import type { Mock } from "vitest";

vi.mock("../../store");
import { createTestingPinia } from "@pinia/testing";
import { useConfigStore } from "../../store";
import { Theme } from "../../models/theme";
import { nextTick } from "vue";

describe("ThemeButton", () => {
let dispatch: Mock;
let theme: Ref<"light" | "dark">;

beforeEach(() => {
theme = ref("light");
dispatch = vi.fn();
vi.mocked(useAppSelector).mockReturnValue(theme);
vi.mocked(useAppDispatch).mockReturnValue(dispatch);
});
const renderOptions = {
global: {
plugins: [createTestingPinia()],
},
};

it("dispatches event to toggle theme", async () => {
render(ThemeButton);
it("toggles theme when clicked", async () => {
render(ThemeButton, renderOptions);
const config = useConfigStore();

await fireEvent.click(screen.getByRole("button"));

expect(dispatch).toBeCalledTimes(1);
expect(toggleTheme).toBeCalledTimes(1);
expect(toggleTheme).toBeCalledWith("light");

theme.value = "dark";
await fireEvent.click(screen.getByRole("button"));
expect(toggleTheme).toBeCalledWith("dark");
expect(config.toggleTheme).toBeCalledTimes(1);
});

it("updates icon based on theme", async () => {
render(ThemeButton);
render(ThemeButton, renderOptions);
const config = useConfigStore();

config.theme = Theme.Light;
await nextTick();
expect(screen.getByRole("button")).toHaveClass("light");
expect(screen.getByRole("button")).not.toHaveClass("dark");

theme.value = "dark";
config.theme = Theme.Dark;
await nextTick();

expect(screen.getByRole("button")).toHaveClass("dark");
expect(screen.getByRole("button")).not.toHaveClass("light");
});
Expand Down
4 changes: 2 additions & 2 deletions src/composables/useAutocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ref, watch, type Ref } from "vue";
import debounce from "lodash.debounce";
import { ScryfallApiInstance } from "../utils/scryfall-api";
import { useAppSelector } from "../store";
import { useConfigRefs } from "../store";

/** The debounce time before doing autocomplete. */
const AUTOCOMPLETE_DELAY = 500;
Expand All @@ -19,8 +19,8 @@ const ENABLE_LOGGING = false;
* @returns A ref to a list of options valid for autocomplete.
*/
export function useAutocomplete(guess: Ref<string>) {
const { autocomplete: acEnabled } = useConfigRefs();
/** Is autocomplete enabled? */
const acEnabled = useAppSelector((state) => state.config.autocomplete);
/** The autocomplete options available. */
const options = ref<string[]>([]);
/** The last time options were updataed. */
Expand Down
9 changes: 3 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { createApp } from "vue";
import "./styles/main.scss";
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { provideStoreToApp } from "@reduxjs/vue-redux";
import { loadConfig, store } from "./store";

const app = createApp(App).use(router);
store.dispatch(loadConfig());
provideStoreToApp(app, { store });
const app = createApp(App).use(createPinia()).use(router);
app.mount("#app");
15 changes: 6 additions & 9 deletions src/pages/CustomGame.vue
Original file line number Diff line number Diff line change
@@ -1,30 +1,27 @@
<script setup lang="ts">
import { onMounted } from "vue";
import GameScreen from "../components/GameScreen.vue";
import { startGame } from "../store";
import { useAppDispatch } from "../store/hooks";
import { useRoute, useRouter } from "vue-router";
import { flattenSearchCriteria } from "../utils/string";
import { COMPATIBILITY_CRITERIA } from "../config";
import { useGameStore } from "../store";

type QueryParams = {
q: string;
include_extras?: "true" | "false";
};

const { startGame } = useGameStore();
const route = useRoute();
const router = useRouter();
const dispatch = useAppDispatch();

const start = (criteria: string, includeExtras?: boolean) => {
const search = flattenSearchCriteria([criteria, ...COMPATIBILITY_CRITERIA]);

dispatch(
startGame({
search,
includeExtras,
})
);
startGame({
search,
includeExtras,
});
};

onMounted(() => {
Expand Down
15 changes: 6 additions & 9 deletions src/pages/FormatGame.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
<script setup lang="ts">
import { onMounted } from "vue";
import GameScreen from "../components/GameScreen.vue";
import { startGame } from "../store";
import { useAppDispatch } from "../store/hooks";
import { useRoute } from "vue-router";
import { flattenSearchCriteria, trimTrailingSlash } from "../utils/string";
import { AVOID_CRITERIA, COMPATIBILITY_CRITERIA } from "../config";
import { useGameStore } from "../store";

const { startGame } = useGameStore();
const route = useRoute();
const dispatch = useAppDispatch();
const formatCriteria = ["-t:stickers", "not:extra", ...COMPATIBILITY_CRITERIA, ...AVOID_CRITERIA];

const start = (criteria: string[]) => {
const search = flattenSearchCriteria(criteria);
dispatch(
startGame({
search,
includeExtras: false,
})
);
startGame({
search,
includeExtras: false,
});
};

onMounted(() => {
Expand Down
5 changes: 2 additions & 3 deletions src/pages/PickModeScreen.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
<script setup lang="ts">
import { computed } from "vue";
import { useAppSelector } from "../store/hooks";
import { LoadingStatus } from "../store/common";
import { LoadingStatus, useGameRefs } from "../store";

type Preset = {
id: string;
label: string;
link: string;
};

const gameLoadStatus = useAppSelector((state) => state.game.status);
const { status: gameLoadStatus } = useGameRefs();

/**
* The supported formats in the art game.
Expand Down
9 changes: 4 additions & 5 deletions src/pages/SettingsScreen.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, useId } from "vue";
import { useAppDispatch, useAppSelector, toggleAutocomplete } from "../store";
import { useConfigRefs, useConfigStore } from "../store";
import { KeyCode } from "../utils/keyboard";
import router from "../router";

const dispatch = useAppDispatch();
const autocomplete = useAppSelector((state) => state.config.autocomplete);

const autocompleteId = useId();
const autocompleteDescId = useId();
const { toggleAutocomplete } = useConfigStore();
const { autocomplete } = useConfigRefs();

const onKeydown = (event: KeyboardEvent) => {
if (event.code === KeyCode.Escape) {
Expand Down Expand Up @@ -47,7 +46,7 @@ onBeforeUnmount(() => {
:aria-describedby="autocompleteId"
type="button"
class="btn btn-small"
@click="() => dispatch(toggleAutocomplete(autocomplete))"
@click="toggleAutocomplete"
>
{{ autocomplete ? "On" : "Off" }}
</button>
Expand Down
Loading